From 8085482a6760168b67424d971e0ed4bf4502dd6a Mon Sep 17 00:00:00 2001 From: Maksim Redzkin Date: Wed, 16 Sep 2026 16:23:21 +0300 Subject: [PATCH] add st.searchPosts action --- package-lock.json | 12 +-- package.json | 4 +- src/linked-api-tools.ts | 2 + src/tools/search-posts.ts | 213 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 src/tools/search-posts.ts diff --git a/package-lock.json b/package-lock.json index b6d73ea..649142a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@linkedapi/mcp", - "version": "2.3.11", + "version": "2.3.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@linkedapi/mcp", - "version": "2.3.11", + "version": "2.3.12", "license": "MIT", "dependencies": { - "@linkedapi/node": "^2.3.11", + "@linkedapi/node": "^2.3.12", "@modelcontextprotocol/sdk": "^1.17.4", "zod": "^4.1.1" }, @@ -921,9 +921,9 @@ } }, "node_modules/@linkedapi/node": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@linkedapi/node/-/node-2.3.11.tgz", - "integrity": "sha512-EnXWxkfG2LxFGcQMlvy1/UekbeBvjx6WXtdSEcADF1Y0ygGQm7q8rg3SqCxUedaDT6SBZyEgR4K0XgaJ6PGzig==", + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@linkedapi/node/-/node-2.3.12.tgz", + "integrity": "sha512-E22zICF/fe/+riwRlnZnxWW76uqZA+/PHmmBFIqHKFXYpDUd0szaYF7SdQ503FRqQkGwBxOFJJhLxdLqvP5JCg==", "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { diff --git a/package.json b/package.json index b9d393b..8be191c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@linkedapi/mcp", - "version": "2.3.11", + "version": "2.3.12", "description": "MCP server that lets AI assistants control LinkedIn accounts and retrieve real-time data.", "main": "dist/index.js", "bin": { @@ -30,7 +30,7 @@ "author": "Linked API", "license": "MIT", "dependencies": { - "@linkedapi/node": "^2.3.11", + "@linkedapi/node": "^2.3.12", "@modelcontextprotocol/sdk": "^1.17.4", "zod": "^4.1.1" }, diff --git a/src/linked-api-tools.ts b/src/linked-api-tools.ts index ffa306c..f9581d1 100644 --- a/src/linked-api-tools.ts +++ b/src/linked-api-tools.ts @@ -56,6 +56,7 @@ import { RetrieveSSITool } from './tools/retrieve-ssi.js'; import { SearchCompaniesTool } from './tools/search-companies.js'; import { SearchJobsTool } from './tools/search-jobs.js'; import { SearchPeopleTool } from './tools/search-people.js'; +import { SearchPostsTool } from './tools/search-posts.js'; import { SendConnectionRequestTool } from './tools/send-connection-request.js'; import { SendFeedbackTool } from './tools/send-feedback.js'; import { SendMessageTool } from './tools/send-message.js'; @@ -103,6 +104,7 @@ export class LinkedApiTools { new SearchCompaniesTool(), new SearchPeopleTool(), new SearchJobsTool(), + new SearchPostsTool(), new FetchCompanyTool(), new FetchPersonTool(), new FetchPostTool(), diff --git a/src/tools/search-posts.ts b/src/tools/search-posts.ts new file mode 100644 index 0000000..62209c4 --- /dev/null +++ b/src/tools/search-posts.ts @@ -0,0 +1,213 @@ +import { OPERATION_NAME, TSearchPostsParams } from '@linkedapi/node'; +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +import { OperationTool } from '../utils/linked-api-tool.js'; + +const PERSON_FILTER_ENTRY_SCHEMA = z.union([ + z.string().min(1).max(100), + z.object({ + name: z.string().min(1).max(100), + urn: z.string().optional(), + personHashedUrl: z.string().optional(), + }), +]); + +const COMPANY_FILTER_ENTRY_SCHEMA = z.union([ + z.string().min(1).max(100), + z.object({ + name: z.string().min(1).max(100), + urn: z.string().optional(), + companyHashedUrl: z.string().optional(), + }), +]); + +const PERSON_FILTER_ENTRY_JSON_SCHEMA = { + oneOf: [ + { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'Name of the person, shorthand for { name }.', + }, + { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'Required. Name to type into the LinkedIn filter panel.', + }, + urn: { + type: 'string', + description: 'Optional. Member URN of the person, urn:li:member:.', + }, + personHashedUrl: { + type: 'string', + description: 'Optional. Hashed LinkedIn URL of the person.', + }, + }, + required: ['name'], + }, + ], +}; + +const COMPANY_FILTER_ENTRY_JSON_SCHEMA = { + oneOf: [ + { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'Name of the company, shorthand for { name }.', + }, + { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'Required. Name to type into the LinkedIn filter panel.', + }, + urn: { + type: 'string', + description: 'Optional. Organization URN of the company, urn:li:organization:.', + }, + companyHashedUrl: { + type: 'string', + description: 'Optional. Hashed LinkedIn URL of the company.', + }, + }, + required: ['name'], + }, + ], +}; + +export class SearchPostsTool extends OperationTool { + public override readonly name = 'search_posts'; + public override readonly operationName = OPERATION_NAME.searchPosts; + protected override readonly schema = z + .object({ + term: z.string().min(1).max(50).optional(), + limit: z.number().min(1).max(100).optional(), + filter: z + .object({ + sort: z.enum(['topMatch', 'latest']).optional(), + datePosted: z.enum(['past24Hours', 'pastWeek', 'pastMonth']).optional(), + contentType: z + .enum(['videos', 'images', 'jobPosts', 'liveVideos', 'documents']) + .optional(), + postedBy: z.array(z.enum(['me', 'firstConnections', 'peopleYouFollow'])).optional(), + fromMembers: z.array(PERSON_FILTER_ENTRY_SCHEMA).optional(), + fromCompanies: z.array(COMPANY_FILTER_ENTRY_SCHEMA).optional(), + mentioningMembers: z.array(PERSON_FILTER_ENTRY_SCHEMA).optional(), + mentioningCompanies: z.array(COMPANY_FILTER_ENTRY_SCHEMA).optional(), + authorCompanies: z.array(COMPANY_FILTER_ENTRY_SCHEMA).optional(), + authorIndustries: z.array(z.string()).optional(), + }) + .optional(), + customSearchUrl: z.string().optional(), + }) + .refine(({ term, customSearchUrl }) => Boolean(term) || Boolean(customSearchUrl), { + message: 'Either term or customSearchUrl must be provided.', + }); + + public override getTool(): Tool { + return { + name: this.name, + description: + 'Allows you to search posts applying various filtering criteria (st.searchPosts action). Either term or customSearchUrl must be provided, and when customSearchUrl is specified, filter is ignored entirely and only the facets already encoded in the URL are applied. LinkedIn widens a narrow query on its own, so a non-empty result does not mean the term matched — check the returned posts against your own criteria when an exact match matters. Unlike fetch_post, the author and reposter of a search result carry no urn, so fetch the post by its url when you need one.', + inputSchema: { + type: 'object', + properties: { + term: { + type: 'string', + minLength: 1, + maxLength: 50, + description: + 'Optional. Keyword or phrase to search, from 1 to 50 characters. Required unless customSearchUrl is specified.', + }, + limit: { + type: 'number', + description: + 'Optional. Number of search results to return. Defaults to 10, with a maximum value of 100. A search may return fewer posts than limit, because how many results come back depends on what LinkedIn loads for that account on that search.', + }, + filter: { + type: 'object', + description: + 'Optional. Filtering criteria for posts. Every specified field is applied, or the action fails. When multiple filter fields are specified, they are combined using AND logic. Ignored entirely when customSearchUrl is specified.', + properties: { + sort: { + type: 'string', + enum: ['topMatch', 'latest'], + description: 'Optional. How to order the results.', + }, + datePosted: { + type: 'string', + enum: ['past24Hours', 'pastWeek', 'pastMonth'], + description: 'Optional. How recently the post was published.', + }, + contentType: { + type: 'string', + enum: ['videos', 'images', 'jobPosts', 'liveVideos', 'documents'], + description: 'Optional. Kind of content the post must carry.', + }, + postedBy: { + type: 'array', + description: + 'Optional. Array of author relationships to you. Matches if the post was published by any of the listed ones.', + items: { + type: 'string', + enum: ['me', 'firstConnections', 'peopleYouFollow'], + }, + }, + fromMembers: { + type: 'array', + description: + 'Optional. Array of people whose posts to keep. Each entry is a plain name string, or an object with name plus an optional urn or personHashedUrl that pins the exact person; both forms can be mixed in one array. With a name alone LinkedIn takes whichever suggestion it ranked first, which may be a namesake. An identifier no suggestion resolves to fails the action with filterIdentityMismatch rather than filtering by a namesake.', + items: PERSON_FILTER_ENTRY_JSON_SCHEMA, + }, + fromCompanies: { + type: 'array', + description: + 'Optional. Array of companies whose posts to keep. Same entry shape as fromMembers, with companyHashedUrl in place of personHashedUrl.', + items: COMPANY_FILTER_ENTRY_JSON_SCHEMA, + }, + mentioningMembers: { + type: 'array', + description: + 'Optional. Array of people to look for in post text. Same entry shape as fromMembers.', + items: PERSON_FILTER_ENTRY_JSON_SCHEMA, + }, + mentioningCompanies: { + type: 'array', + description: + 'Optional. Array of companies to look for in post text. Same entry shape as fromCompanies.', + items: COMPANY_FILTER_ENTRY_JSON_SCHEMA, + }, + authorCompanies: { + type: 'array', + description: + 'Optional. Array of companies the author works at. Same entry shape as fromCompanies.', + items: COMPANY_FILTER_ENTRY_JSON_SCHEMA, + }, + authorIndustries: { + type: 'array', + description: + 'Optional. Array of industry names the author works in. An industry is a taxonomy value rather than an entity, so it is matched by name only. Takes specific values available in the LinkedIn interface.', + items: { type: 'string' }, + }, + }, + }, + customSearchUrl: { + type: 'string', + description: + 'Optional. URL copied from a LinkedIn content search page after configuring filters. When specified, filter is ignored entirely.', + }, + }, + anyOf: [{ required: ['term'] }, { required: ['customSearchUrl'] }], + } as Tool['inputSchema'], + }; + } +}