From 18e21c0ec039451e077b78ab9ca0895a6e260416 Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 13 Jun 2025 14:32:49 +0200 Subject: [PATCH 1/9] disable search for rental Actors --- src/tools/store_collection.ts | 75 +++++++++++++++++++++++++++++++++-- src/types.ts | 2 + 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index b6debf1f..3d6ed026 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -5,7 +5,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.js'; +import type { ActorStorePruned, ApifyStorePricingModel, HelperTool, PricingInfo, ToolEntry } from '../types.js'; function pruneActorStoreInfo(response: ActorStoreList): ActorStorePruned { const stats = response.stats || {}; @@ -38,9 +38,10 @@ export async function searchActorsByKeywords( apifyToken: string, limit: number | undefined = undefined, offset: number | undefined = undefined, + pricingModel: ApifyStorePricingModel | undefined = undefined, ): Promise { const client = new ApifyClient({ token: apifyToken }); - const results = await client.store().list({ search, limit, offset }); + const results = await client.store().list({ search, limit, offset, pricingModel }); return results.items.map((x) => pruneActorStoreInfo(x)); } @@ -68,6 +69,61 @@ export const searchActorsArgsSchema = z.object({ .describe('Filters the results by the specified category.'), }); +/** + * Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors). + * + * @param actors - Array of ActorStorePruned objects to filter. + * @returns Array of actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model. + */ +function filterRentalActors( + actors: ActorStorePruned[], +): ActorStorePruned[] { + // Store list API does not support filtering by two pricing models at once, + // so we filter the results manually after fetching them. + return actors.filter((actor) => (actor.currentPricingInfo.pricingModel as ApifyStorePricingModel) !== 'FLAT_PRICE_PER_MONTH'); +} + +/** + * Fallback function to fetch actors if no rental actors are found. + * Fetches both free and pay-per-result actors and merges them in a zig-zag order. + * + * @param search - Search keywords for actors. + * @param apifyToken - Apify API token. + * @param limit - Maximum number of actors to return. + * @param offset - Number of actors to skip from the start. + * @returns Array of ActorStorePruned objects, alternating between free and pay-per-result actors. + */ +async function getFallbackActors( + search: string, + apifyToken: string, + limit: number | undefined, + offset: number | undefined, +): Promise { + const freeActors = await searchActorsByKeywords( + search, + apifyToken, + limit, + offset, + 'FREE', + ); + const payPerResultActors = await searchActorsByKeywords( + search, + apifyToken, + limit, + offset, + 'PRICE_PER_DATASET_ITEM', + ); + const allActors: ActorStorePruned[] = []; + // Push Actors in zig-zag order to ensure that we return all Actors + // in relevant order. + const maxLength = Math.max(freeActors?.length || 0, payPerResultActors?.length || 0); + for (let i = 0; i < maxLength; i++) { + if (freeActors && freeActors[i]) allActors.push(freeActors[i]); + if (payPerResultActors && payPerResultActors[i]) allActors.push(payPerResultActors[i]); + } + return allActors; +} + /** * https://docs.apify.com/api/v2/store-get */ @@ -88,12 +144,25 @@ export const searchActors: ToolEntry = { call: async (toolArgs) => { const { args, apifyToken } = toolArgs; const parsed = searchActorsArgsSchema.parse(args); - const actors = await searchActorsByKeywords( + let actors = await searchActorsByKeywords( parsed.search, apifyToken, parsed.limit, parsed.offset, ); + actors = filterRentalActors(actors || []); + if (actors.length === 0) { + // If no non-rental actors found, search for free and pay-per-result actors directly + // and sort them by total stars. + // This is a fallback to ensure we return some results. + actors = await getFallbackActors( + parsed.search, + apifyToken, + parsed.limit, + parsed.offset, + ); + } + return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) }; }, } as HelperTool, diff --git a/src/types.ts b/src/types.ts index ad8a4b91..ae92fe26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -190,3 +190,5 @@ export interface ToolCacheEntry { expiresAt: number; tool: ToolEntry; } + +export type ApifyStorePricingModel = 'FREE' | 'FLAT_PRICE_PER_MONTH' | 'PRICE_PER_DATASET_ITEM'; From 1fad2b7988cdda258e124ae537eb0c97d667da7a Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 13 Jun 2025 14:36:09 +0200 Subject: [PATCH 2/9] fix: update return type of searchActorsByKeywords to ensure consistent promise resolution --- src/tools/store_collection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 3d6ed026..35ede1d5 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -39,7 +39,7 @@ export async function searchActorsByKeywords( limit: number | undefined = undefined, offset: number | undefined = undefined, pricingModel: ApifyStorePricingModel | undefined = undefined, -): Promise { +): Promise { const client = new ApifyClient({ token: apifyToken }); const results = await client.store().list({ search, limit, offset, pricingModel }); return results.items.map((x) => pruneActorStoreInfo(x)); From 415829bb4136fea09ab16b60f47dcafddc330981 Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 20 Jun 2025 12:01:37 +0200 Subject: [PATCH 3/9] simplify implementation, todo get rented Actors from mongo --- src/const.ts | 16 ++++++++ src/tools/store_collection.ts | 69 +++++------------------------------ src/types.ts | 4 +- 3 files changed, 28 insertions(+), 61 deletions(-) diff --git a/src/const.ts b/src/const.ts index 7a82b8b8..b27f5d3c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -56,3 +56,19 @@ export const ACTOR_ADDITIONAL_INSTRUCTIONS = `Never call/execute tool/Actor unle export const TOOL_CACHE_MAX_SIZE = 500; export const TOOL_CACHE_TTL_SECS = 30 * 60; + +export const ACTOR_PRICING_MODEL = { + /** Rental actors */ + FLAT_PRICE_PER_MONTH: 'FLAT_PRICE_PER_MONTH', + FREE: 'FREE', + /** Pay per result (PPR) actors */ + PRICE_PER_DATASET_ITEM: 'PRICE_PER_DATASET_ITEM', + /** Pay per event (PPE) actors */ + PAY_PER_EVENT: 'PAY_PER_EVENT', +} as const; + +/** + * Used in search Actors tool to search above the input supplied limit, + * so we can safely filter out rental Actors from the search and ensure we return some results. + */ +export const ACTOR_SEARCH_ABOVE_LIMIT = 50; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 35ede1d5..3ac3affc 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; -import { HelperTools } from '../const.js'; -import type { ActorStorePruned, ApifyStorePricingModel, HelperTool, PricingInfo, ToolEntry } from '../types.js'; +import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js'; +import type { ActorPricingModel, ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.js'; function pruneActorStoreInfo(response: ActorStoreList): ActorStorePruned { const stats = response.stats || {}; @@ -38,10 +38,9 @@ export async function searchActorsByKeywords( apifyToken: string, limit: number | undefined = undefined, offset: number | undefined = undefined, - pricingModel: ApifyStorePricingModel | undefined = undefined, ): Promise { const client = new ApifyClient({ token: apifyToken }); - const results = await client.store().list({ search, limit, offset, pricingModel }); + const results = await client.store().list({ search, limit, offset }); return results.items.map((x) => pruneActorStoreInfo(x)); } @@ -70,7 +69,9 @@ export const searchActorsArgsSchema = z.object({ }); /** - * Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors). + * Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors).. + * + * Returns new array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model. * * @param actors - Array of ActorStorePruned objects to filter. * @returns Array of actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model. @@ -80,48 +81,7 @@ function filterRentalActors( ): ActorStorePruned[] { // Store list API does not support filtering by two pricing models at once, // so we filter the results manually after fetching them. - return actors.filter((actor) => (actor.currentPricingInfo.pricingModel as ApifyStorePricingModel) !== 'FLAT_PRICE_PER_MONTH'); -} - -/** - * Fallback function to fetch actors if no rental actors are found. - * Fetches both free and pay-per-result actors and merges them in a zig-zag order. - * - * @param search - Search keywords for actors. - * @param apifyToken - Apify API token. - * @param limit - Maximum number of actors to return. - * @param offset - Number of actors to skip from the start. - * @returns Array of ActorStorePruned objects, alternating between free and pay-per-result actors. - */ -async function getFallbackActors( - search: string, - apifyToken: string, - limit: number | undefined, - offset: number | undefined, -): Promise { - const freeActors = await searchActorsByKeywords( - search, - apifyToken, - limit, - offset, - 'FREE', - ); - const payPerResultActors = await searchActorsByKeywords( - search, - apifyToken, - limit, - offset, - 'PRICE_PER_DATASET_ITEM', - ); - const allActors: ActorStorePruned[] = []; - // Push Actors in zig-zag order to ensure that we return all Actors - // in relevant order. - const maxLength = Math.max(freeActors?.length || 0, payPerResultActors?.length || 0); - for (let i = 0; i < maxLength; i++) { - if (freeActors && freeActors[i]) allActors.push(freeActors[i]); - if (payPerResultActors && payPerResultActors[i]) allActors.push(payPerResultActors[i]); - } - return allActors; + return actors.filter((actor) => (actor.currentPricingInfo.pricingModel as ActorPricingModel) !== 'FLAT_PRICE_PER_MONTH'); } /** @@ -147,21 +107,10 @@ export const searchActors: ToolEntry = { let actors = await searchActorsByKeywords( parsed.search, apifyToken, - parsed.limit, + parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT, parsed.offset, ); - actors = filterRentalActors(actors || []); - if (actors.length === 0) { - // If no non-rental actors found, search for free and pay-per-result actors directly - // and sort them by total stars. - // This is a fallback to ensure we return some results. - actors = await getFallbackActors( - parsed.search, - apifyToken, - parsed.limit, - parsed.offset, - ); - } + actors = filterRentalActors(actors || []).slice(0, parsed.limit); return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) }; }, diff --git a/src/types.ts b/src/types.ts index ae92fe26..7cb53a8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { ValidateFunction } from 'ajv'; import type { ActorDefaultRunOptions, ActorDefinition } from 'apify-client'; +import type { ACTOR_PRICING_MODEL } from './const.js'; import type { ActorsMcpServer } from './mcp/server.js'; export interface ISchemaProperties { @@ -191,4 +192,5 @@ export interface ToolCacheEntry { tool: ToolEntry; } -export type ApifyStorePricingModel = 'FREE' | 'FLAT_PRICE_PER_MONTH' | 'PRICE_PER_DATASET_ITEM'; +// Utility type to get the union of values of an object type +export type ActorPricingModel = (typeof ACTOR_PRICING_MODEL)[keyof typeof ACTOR_PRICING_MODEL]; From cb2e8e0d03d9853d3fbbf01fd6ecb50806a6c68f Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 20 Jun 2025 14:52:36 +0200 Subject: [PATCH 4/9] add support for filtering if user rented Actor Ids list if passed from apify-mcp-server --- src/mcp/server.ts | 4 ++++ src/tools/store_collection.ts | 19 +++++++++++++------ src/types.ts | 2 ++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8d030a24..17fbc877 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -348,9 +348,12 @@ export class ActorsMcpServer { // eslint-disable-next-line prefer-const let { name, arguments: args } = request.params; const apifyToken = (request.params.apifyToken || process.env.APIFY_TOKEN) as string; + const userRentedActorIds = request.params.userRentedActorIds as string[] | undefined; // Remove apifyToken from request.params just in case delete request.params.apifyToken; + // Remove other custom params passed from apify-mcp-server + delete request.params.userRentedActorIds; // Validate token if (!apifyToken) { @@ -415,6 +418,7 @@ export class ActorsMcpServer { apifyMcpServer: this, mcpServer: this.server, apifyToken, + userRentedActorIds, }) as object; return { ...res }; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 3ac3affc..e0af5b7c 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -69,19 +69,26 @@ export const searchActorsArgsSchema = z.object({ }); /** - * Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors).. + * Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors), + * unless the actor's ID is present in the user's rented actor IDs list. * - * Returns new array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model. + * This is necessary because the Store list API does not support filtering by multiple pricing models at once. * * @param actors - Array of ActorStorePruned objects to filter. - * @returns Array of actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model. + * @param userRentedActorIds - Array of actor IDs that the user has rented. + * @returns Array of actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model, + * except for actors that the user has rented (whose IDs are in userRentedActorIds). */ function filterRentalActors( actors: ActorStorePruned[], + userRentedActorIds: string[], ): ActorStorePruned[] { // Store list API does not support filtering by two pricing models at once, // so we filter the results manually after fetching them. - return actors.filter((actor) => (actor.currentPricingInfo.pricingModel as ActorPricingModel) !== 'FLAT_PRICE_PER_MONTH'); + return actors.filter((actor) => ( + actor.currentPricingInfo.pricingModel as ActorPricingModel) !== 'FLAT_PRICE_PER_MONTH' + || userRentedActorIds.includes(actor.id), + ); } /** @@ -102,7 +109,7 @@ export const searchActors: ToolEntry = { inputSchema: zodToJsonSchema(searchActorsArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, apifyToken, userRentedActorIds } = toolArgs; const parsed = searchActorsArgsSchema.parse(args); let actors = await searchActorsByKeywords( parsed.search, @@ -110,7 +117,7 @@ export const searchActors: ToolEntry = { parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT, parsed.offset, ); - actors = filterRentalActors(actors || []).slice(0, parsed.limit); + actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) }; }, diff --git a/src/types.ts b/src/types.ts index 7cb53a8e..140f52c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,8 @@ export type InternalToolArgs = { mcpServer: Server; /** Apify API token */ apifyToken: string; + /** List of Actor IDs that the user has rented */ + userRentedActorIds?: string[]; } /** From ef4737a8265820cb70bd279c01c8ece69c3d1013 Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 20 Jun 2025 14:56:25 +0200 Subject: [PATCH 5/9] add naive integration test --- tests/integration/suite.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index c1bf1695..37173210 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -175,6 +175,31 @@ export function createIntegrationTestsSuite( await client.close(); }); + // It should filter out all rental Actors only if we run locally or as standby, where + // we cannot access MongoDB to get the user's rented Actors. + // In case of apify-mcp-server it should include user's rented Actors. + it('should filter out all rental Actors from store search', async () => { + const client = await createClientFn(); + + const result = await client.callTool({ + name: HelperTools.STORE_SEARCH, + arguments: { + search: 'rental', + limit: 100, + }, + }); + const content = result.content as {text: string}[]; + const actors = content.map((item) => JSON.parse(item.text)); + expect(actors.length).toBeGreaterThan(0); + + // Check that no rental Actors are present + for (const actor of actors) { + expect(actor.currentPricingInfo.pricingModel).not.toBe('FLAT_PRICE_PER_MONTH'); + } + + await client.close(); + }); + // Execute only when we can get the MCP server instance - currently skips only stdio // is skipped because we are running a compiled version through node and there is no way (easy) // to get the MCP server instance From 968538afd7b15e20b5d3db869c4820bcbb091f8e Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 20 Jun 2025 16:10:14 +0200 Subject: [PATCH 6/9] merge master --- .actor/ACTOR.md | 2 -- CHANGELOG.md | 11 +++++++++++ README.md | 2 -- package-lock.json | 4 ++-- package.json | 2 +- src/actor/server.ts | 2 +- src/mcp/server.ts | 31 ++++++++++++++++++++++++++----- src/tools/helpers.ts | 10 +++++----- src/types.ts | 9 +++++++++ tests/integration/suite.ts | 20 ++++++++++++++++++++ 10 files changed, 75 insertions(+), 18 deletions(-) diff --git a/.actor/ACTOR.md b/.actor/ACTOR.md index 0d361253..72cf2273 100644 --- a/.actor/ACTOR.md +++ b/.actor/ACTOR.md @@ -72,9 +72,7 @@ Any [Apify Actor](https://apify.com/store) can be used as a tool. By default, the server is pre-configured with the Actors specified below, but this can be overridden by providing Actor input. ```text -'apify/instagram-scraper' 'apify/rag-web-browser' -'lukaskrivka/google-maps-with-contact-details' ``` The MCP server loads the Actor input schema and creates MCP tools corresponding to the Actors. See this example of input schema for the [RAG Web Browser](https://apify.com/apify/rag-web-browser/input-schema). diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0fa210..dd957d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. + +## 0.2.7 - **not yet released** + +### 🐛 Bug Fixes + +- Explicitly clear resources ([#136](https://github.com/apify/actors-mcp-server/pull/136)) ([779d2ba](https://github.com/apify/actors-mcp-server/commit/779d2ba2407bcd5fbdd89d3201463a784e67c931)) by [@jirispilka](https://github.com/jirispilka) +- Readme Actors list ([#141](https://github.com/apify/actors-mcp-server/pull/141)) ([dc0a332](https://github.com/apify/actors-mcp-server/commit/dc0a332c8dbe450290d4acb5a19759545edf3c32)) by [@MQ37](https://github.com/MQ37) +- Notifications ([#145](https://github.com/apify/actors-mcp-server/pull/145)) ([d96c427](https://github.com/apify/actors-mcp-server/commit/d96c42775db86f563c1012285c4a42f12fc23a19)) by [@MQ37](https://github.com/MQ37) + + + ## [0.2.6](https://github.com/apify/actors-mcp-server/releases/tag/v0.2.6) (2025-06-13) ### 🐛 Bug Fixes diff --git a/README.md b/README.md index a6091c09..737f3990 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,7 @@ Any [Apify Actor](https://apify.com/store) can be used as a tool. By default, the server is pre-configured with the Actors specified below, but this can be overridden by providing Actor input. ```text -'apify/instagram-scraper' 'apify/rag-web-browser' -'lukaskrivka/google-maps-with-contact-details' ``` The MCP server loads the Actor input schema and creates MCP tools corresponding to the Actors. See this example of input schema for the [RAG Web Browser](https://apify.com/apify/rag-web-browser/input-schema). diff --git a/package-lock.json b/package-lock.json index 15a4a5f3..11c970d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apify/actors-mcp-server", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apify/actors-mcp-server", - "version": "0.2.6", + "version": "0.2.7", "license": "MIT", "dependencies": { "@apify/datastructures": "^2.0.3", diff --git a/package.json b/package.json index f6b402f6..757d104e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apify/actors-mcp-server", - "version": "0.2.6", + "version": "0.2.7", "type": "module", "description": "Model Context Protocol Server for Apify", "engines": { diff --git a/src/actor/server.ts b/src/actor/server.ts index a57c2285..95b51c68 100644 --- a/src/actor/server.ts +++ b/src/actor/server.ts @@ -130,7 +130,7 @@ export function createExpressApp( // New initialization request - use JSON response mode transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, // Enable JSON response mode + enableJsonResponse: false, // Use SSE response mode }); // Load MCP server tools await loadToolsAndActors(mcpServer, req.url, process.env.APIFY_TOKEN as string); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 17fbc877..5a1fbf33 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -12,6 +12,7 @@ import { ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; +import type { ValidateFunction } from 'ajv'; import { type ActorCallOptions, ApifyApiError } from 'apify-client'; import log from '@apify/log'; @@ -45,6 +46,7 @@ export class ActorsMcpServer { public readonly tools: Map; private options: ActorsMcpServerOptions; private toolsChangedHandler: ToolsChangedHandler | undefined; + private sigintHandler: (() => Promise) | undefined; constructor(options: ActorsMcpServerOptions = {}, setupSigintHandler = true) { this.options = { @@ -292,7 +294,6 @@ export class ActorsMcpServer { public upsertTools(tools: ToolEntry[], shouldNotifyToolsChangedHandler = false) { for (const wrap of tools) { this.tools.set(wrap.tool.name, wrap); - log.info(`Added/updated tool: ${wrap.tool.name}`); } if (shouldNotifyToolsChangedHandler) this.notifyToolsChangedHandler(); return tools; @@ -319,12 +320,13 @@ export class ActorsMcpServer { this.server.onerror = (error) => { console.error('[MCP Error]', error); // eslint-disable-line no-console }; - // Allow disabling of the SIGINT handler to prevent max listeners warning if (setupSIGINTHandler) { - process.on('SIGINT', async () => { + const handler = async () => { await this.server.close(); process.exit(0); - }); + }; + process.once('SIGINT', handler); + this.sigintHandler = handler; // Store the actual handler } } @@ -342,9 +344,10 @@ export class ActorsMcpServer { /** * Handles the request to call a tool. * @param {object} request - The request object containing tool name and arguments. + * @param {object} extra - Extra data given to the request handler, such as sendNotification function. * @throws {McpError} - based on the McpServer class code from the typescript MCP SDK */ - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { // eslint-disable-next-line prefer-const let { name, arguments: args } = request.params; const apifyToken = (request.params.apifyToken || process.env.APIFY_TOKEN) as string; @@ -415,6 +418,7 @@ export class ActorsMcpServer { const internalTool = tool.tool as HelperTool; const res = await internalTool.call({ args, + extra, apifyMcpServer: this, mcpServer: this.server, apifyToken, @@ -501,6 +505,23 @@ export class ActorsMcpServer { } async close(): Promise { + // Remove SIGINT handler + if (this.sigintHandler) { + process.removeListener('SIGINT', this.sigintHandler); + this.sigintHandler = undefined; + } + // Clear all tools and their compiled schemas + for (const tool of this.tools.values()) { + if (tool.tool.ajvValidate && typeof tool.tool.ajvValidate === 'function') { + (tool.tool as { ajvValidate: ValidateFunction | null }).ajvValidate = null; + } + } + this.tools.clear(); + // Unregister tools changed handler + if (this.toolsChangedHandler) { + this.unregisterToolsChangedHandler(); + } + // Close server (which should also remove its event handlers) await this.server.close(); } } diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 57e87187..1c119b61 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -78,7 +78,7 @@ export const addTool: ToolEntry = { ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool call: async (toolArgs) => { - const { apifyMcpServer, mcpServer, apifyToken, args } = toolArgs; + const { apifyMcpServer, apifyToken, args, extra: { sendNotification } } = toolArgs; const parsed = addToolArgsSchema.parse(args); if (apifyMcpServer.listAllToolNames().includes(parsed.actorName)) { return { @@ -90,7 +90,7 @@ export const addTool: ToolEntry = { } const tools = await getActorsAsTools([parsed.actorName], apifyToken); const toolsAdded = apifyMcpServer.upsertTools(tools, true); - await mcpServer.notification({ method: 'notifications/tools/list_changed' }); + await sendNotification({ method: 'notifications/tools/list_changed' }); return { content: [{ @@ -121,13 +121,13 @@ export const removeTool: ToolEntry = { ajvValidate: ajv.compile(zodToJsonSchema(removeToolArgsSchema)), // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool call: async (toolArgs) => { - const { apifyMcpServer, mcpServer, args } = toolArgs; + const { apifyMcpServer, args, extra: { sendNotification } } = toolArgs; const parsed = removeToolArgsSchema.parse(args); // Check if tool exists before attempting removal if (!apifyMcpServer.tools.has(parsed.toolName)) { // Send notification so client can update its tool list // just in case the client tool list is out of sync - await mcpServer.notification({ method: 'notifications/tools/list_changed' }); + await sendNotification({ method: 'notifications/tools/list_changed' }); return { content: [{ type: 'text', @@ -136,7 +136,7 @@ export const removeTool: ToolEntry = { }; } const removedTools = apifyMcpServer.removeToolsByName([parsed.toolName], true); - await mcpServer.notification({ method: 'notifications/tools/list_changed' }); + await sendNotification({ method: 'notifications/tools/list_changed' }); return { content: [{ type: 'text', text: `Tools removed: ${removedTools.join(', ')}` }] }; }, } as InternalTool, diff --git a/src/types.ts b/src/types.ts index 140f52c0..c6105659 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { Notification, Request } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; import type { ActorDefaultRunOptions, ActorDefinition } from 'apify-client'; @@ -81,6 +83,13 @@ export interface ActorTool extends ToolBase { export type InternalToolArgs = { /** Arguments passed to the tool */ args: Record; + /** Extra data given to request handlers. + * + * Can be used to send notifications from the server to the client. + * + * For more details see: https://github.com/modelcontextprotocol/typescript-sdk/blob/f822c1255edcf98c4e73b9bf17a9dd1b03f86716/src/shared/protocol.ts#L102 + */ + extra: RequestHandlerExtra; /** Reference to the Apify MCP server instance */ apifyMcpServer: ActorsMcpServer; /** Reference to the MCP server instance */ diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 37173210..bd1a7e8e 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -1,4 +1,5 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { defaults, HelperTools } from '../../src/const.js'; @@ -354,5 +355,24 @@ export function createIntegrationTestsSuite( expect(notificationCount).toBe(1); await client.close(); }); + + it('should notify client about tool list changed', async () => { + const client = await createClientFn({ enableAddingActors: true }); + + // This flag is set to true when a 'notifications/tools/list_changed' notification is received, + // indicating that the tool list has been updated dynamically. + let hasReceivedNotification = false; + client.setNotificationHandler(ToolListChangedNotificationSchema, async (notification) => { + if (notification.method === 'notifications/tools/list_changed') { + hasReceivedNotification = true; + } + }); + // Add Actor dynamically + await client.callTool({ name: HelperTools.ACTOR_ADD, arguments: { actorName: ACTOR_PYTHON_EXAMPLE } }); + + expect(hasReceivedNotification).toBe(true); + + await client.close(); + }); }); } From f648696e110e053c90f081f8d4f06e34d0c813c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Mon, 23 Jun 2025 08:58:47 +0200 Subject: [PATCH 7/9] Update src/types.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jiří Spilka --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index c6105659..cb4fb39e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,5 +203,5 @@ export interface ToolCacheEntry { tool: ToolEntry; } -// Utility type to get the union of values of an object type +// Utility type to get a union of values from an object type export type ActorPricingModel = (typeof ACTOR_PRICING_MODEL)[keyof typeof ACTOR_PRICING_MODEL]; From 6aec73a57988f1d8ae230c928d7cc82aaf32b43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Mon, 23 Jun 2025 08:58:55 +0200 Subject: [PATCH 8/9] Update src/tools/store_collection.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jiří Spilka --- src/tools/store_collection.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index e0af5b7c..4f8e91df 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -75,9 +75,8 @@ export const searchActorsArgsSchema = z.object({ * This is necessary because the Store list API does not support filtering by multiple pricing models at once. * * @param actors - Array of ActorStorePruned objects to filter. - * @param userRentedActorIds - Array of actor IDs that the user has rented. - * @returns Array of actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model, - * except for actors that the user has rented (whose IDs are in userRentedActorIds). + * @param userRentedActorIds - Array of Actor IDs that the user has rented. + * @returns Array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model (= rental Actors), except for Actors that the user has rented (whose IDs are in userRentedActorIds). */ function filterRentalActors( actors: ActorStorePruned[], From 938e22965de79dec52102823264068b5f51e108e Mon Sep 17 00:00:00 2001 From: MQ Date: Mon, 23 Jun 2025 12:59:35 +0200 Subject: [PATCH 9/9] fix lint --- src/tools/store_collection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 4f8e91df..789ccbf1 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -76,7 +76,8 @@ export const searchActorsArgsSchema = z.object({ * * @param actors - Array of ActorStorePruned objects to filter. * @param userRentedActorIds - Array of Actor IDs that the user has rented. - * @returns Array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model (= rental Actors), except for Actors that the user has rented (whose IDs are in userRentedActorIds). + * @returns Array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model (= rental Actors), + * except for Actors that the user has rented (whose IDs are in userRentedActorIds). */ function filterRentalActors( actors: ActorStorePruned[],