UNPKG

@apify/actors-mcp-server

Version:

Model Context Protocol Server for Apify

550 lines 23.5 kB
/** * Model Context Protocol (MCP) server for Apify Actors */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, CallToolResultSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, McpError, ServerNotificationSchema, } from '@modelcontextprotocol/sdk/types.js'; import { ApifyApiError } from 'apify-client'; import log from '@apify/log'; import { defaults, SERVER_NAME, SERVER_VERSION, } from '../const.js'; import { prompts } from '../prompts/index.js'; import { addRemoveTools, callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js'; import { actorNameToToolName, decodeDotPropertyNames } from '../tools/utils.js'; import { createProgressTracker } from '../utils/progress.js'; import { getToolPublicFieldOnly } from '../utils/tools.js'; import { connectMCPClient } from './client.js'; import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC } from './const.js'; import { processParamsGetTools } from './utils.js'; /** * Create Apify MCP server */ export class ActorsMcpServer { constructor(options = {}, setupSigintHandler = true) { var _a, _b; Object.defineProperty(this, "server", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "tools", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "options", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "toolsChangedHandler", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "sigintHandler", { enumerable: true, configurable: true, writable: true, value: void 0 }); this.options = { enableAddingActors: (_a = options.enableAddingActors) !== null && _a !== void 0 ? _a : true, enableDefaultActors: (_b = options.enableDefaultActors) !== null && _b !== void 0 ? _b : true, // Default to true for backward compatibility }; this.server = new Server({ name: SERVER_NAME, version: SERVER_VERSION, }, { capabilities: { tools: { listChanged: true }, prompts: {}, logging: {}, }, }); this.tools = new Map(); this.setupErrorHandling(setupSigintHandler); this.setupToolHandlers(); this.setupPromptHandlers(); // Add default tools this.upsertTools(defaultTools); // Add tools to dynamically load Actors if (this.options.enableAddingActors) { this.enableDynamicActorTools(); } // Initialize automatically for backward compatibility this.initialize().catch((error) => { log.error('Failed to initialize server', { error }); }); } /** * Returns an array of tool names. * @returns {string[]} - An array of tool names. */ listToolNames() { return Array.from(this.tools.keys()); } /** * Register handler to get notified when tools change. * The handler receives an array of tool names that the server has after the change. * This is primarily used to store the tools in shared state (e.g., Redis) for recovery * when the server loses local state. * @throws {Error} - If a handler is already registered. * @param handler - The handler function to be called when tools change. */ registerToolsChangedHandler(handler) { if (this.toolsChangedHandler) { throw new Error('Tools changed handler is already registered.'); } this.toolsChangedHandler = handler; } /** * Unregister the handler for tools changed event. * @throws {Error} - If no handler is currently registered. */ unregisterToolsChangedHandler() { if (!this.toolsChangedHandler) { throw new Error('Tools changed handler is not registered.'); } this.toolsChangedHandler = undefined; } /** * Returns the list of all internal tool names * @returns {string[]} - Array of loaded tool IDs (e.g., 'apify/rag-web-browser') */ listInternalToolNames() { return Array.from(this.tools.values()) .filter((tool) => tool.type === 'internal') .map((tool) => tool.tool.name); } /** * Returns the list of all currently loaded Actor tool IDs. * @returns {string[]} - Array of loaded Actor tool IDs (e.g., 'apify/rag-web-browser') */ listActorToolNames() { return Array.from(this.tools.values()) .filter((tool) => tool.type === 'actor') .map((tool) => tool.tool.actorFullName); } /** * Returns a list of Actor IDs that are registered as MCP servers. * @returns {string[]} - An array of Actor MCP server Actor IDs (e.g., 'apify/actors-mcp-server'). */ listActorMcpServerToolIds() { const ids = Array.from(this.tools.values()) .filter((tool) => tool.type === 'actor-mcp') .map((tool) => tool.tool.actorId); // Ensure uniqueness return Array.from(new Set(ids)); } /** * Returns a list of Actor name and MCP server tool IDs. * @returns {string[]} - An array of Actor MCP server Actor IDs (e.g., 'apify/actors-mcp-server'). */ listAllToolNames() { return [...this.listInternalToolNames(), ...this.listActorToolNames(), ...this.listActorMcpServerToolIds()]; } /** * Loads missing toolNames from a provided list of tool names. * Skips toolNames that are already loaded and loads only the missing ones. * @param toolNames - Array of tool names to ensure are loaded * @param apifyToken - Apify API token for authentication */ async loadToolsByName(toolNames, apifyToken) { const loadedTools = this.listAllToolNames(); const actorsToLoad = []; const toolsToLoad = []; const internalToolMap = new Map([ ...defaultTools, ...addRemoveTools, ...Object.values(toolCategories).flat(), ].map((tool) => [tool.tool.name, tool])); for (const tool of toolNames) { // Skip if the tool is already loaded if (loadedTools.includes(tool)) continue; // Load internal tool if (internalToolMap.has(tool)) { toolsToLoad.push(internalToolMap.get(tool)); // Load Actor } else { actorsToLoad.push(tool); } } if (toolsToLoad.length > 0) { this.upsertTools(toolsToLoad); } if (actorsToLoad.length > 0) { const actorTools = await getActorsAsTools(actorsToLoad, apifyToken); if (actorTools.length > 0) { this.upsertTools(actorTools); } } } /** * Resets the server to the default state. * This method clears all tools and loads the default tools. * Used primarily for testing purposes. */ async reset() { this.tools.clear(); // Unregister the tools changed handler if (this.toolsChangedHandler) { this.unregisterToolsChangedHandler(); } this.upsertTools(defaultTools); if (this.options.enableAddingActors) { this.enableDynamicActorTools(); } // Initialize automatically for backward compatibility await this.initialize(); } /** * Initialize the server with default tools if enabled */ async initialize() { if (this.options.enableDefaultActors) { await this.loadDefaultActors(process.env.APIFY_TOKEN); } } /** * Loads default tools if not already loaded. * @param apifyToken - Apify API token for authentication * @returns {Promise<void>} - A promise that resolves when the tools are loaded */ async loadDefaultActors(apifyToken) { const missingActors = defaults.actors.filter((name) => !this.tools.has(actorNameToToolName(name))); const tools = await getActorsAsTools(missingActors, apifyToken); if (tools.length > 0) { log.debug('Loading default tools'); this.upsertTools(tools); } } /** * @deprecated Use `loadDefaultActors` instead. * Loads default tools if not already loaded. */ async loadDefaultTools(apifyToken) { await this.loadDefaultActors(apifyToken); } /** * Loads tools from URL params. * * This method also handles enabling of Actor autoloading via the processParamsGetTools. * * Used primarily for SSE. */ async loadToolsFromUrl(url, apifyToken) { const tools = await processParamsGetTools(url, apifyToken); if (tools.length > 0) { log.debug('Loading tools from query parameters'); this.upsertTools(tools, false); } } /** * Add Actors to server dynamically */ enableDynamicActorTools() { this.options.enableAddingActors = true; this.upsertTools(addRemoveTools, false); } disableDynamicActorTools() { this.options.enableAddingActors = false; this.removeToolsByName(addRemoveTools.map((tool) => tool.tool.name)); } /** Delete tools from the server and notify the handler. */ removeToolsByName(toolNames, shouldNotifyToolsChangedHandler = false) { const removedTools = []; for (const toolName of toolNames) { if (this.removeToolByName(toolName)) { removedTools.push(toolName); } } if (removedTools.length > 0) { if (shouldNotifyToolsChangedHandler) this.notifyToolsChangedHandler(); } return removedTools; } /** * Upsert new tools. * @param tools - Array of tool wrappers to add or update * @param shouldNotifyToolsChangedHandler - Whether to notify the tools changed handler * @returns Array of added/updated tool wrappers */ upsertTools(tools, shouldNotifyToolsChangedHandler = false) { for (const wrap of tools) { this.tools.set(wrap.tool.name, wrap); } if (shouldNotifyToolsChangedHandler) this.notifyToolsChangedHandler(); return tools; } notifyToolsChangedHandler() { // If no handler is registered, do nothing if (!this.toolsChangedHandler) return; // Get the list of tool names this.toolsChangedHandler(this.listAllToolNames()); } removeToolByName(toolName) { if (this.tools.has(toolName)) { this.tools.delete(toolName); log.debug('Deleted tool', { toolName }); return true; } return false; } setupErrorHandling(setupSIGINTHandler = true) { this.server.onerror = (error) => { console.error('[MCP Error]', error); // eslint-disable-line no-console }; if (setupSIGINTHandler) { const handler = async () => { await this.server.close(); process.exit(0); }; process.once('SIGINT', handler); this.sigintHandler = handler; // Store the actual handler } } /** * Sets up MCP request handlers for prompts. */ setupPromptHandlers() { /** * Handles the prompts/list request. */ this.server.setRequestHandler(ListPromptsRequestSchema, () => { return { prompts }; }); /** * Handles the prompts/get request. */ this.server.setRequestHandler(GetPromptRequestSchema, (request) => { const { name, arguments: args } = request.params; const prompt = prompts.find((p) => p.name === name); if (!prompt) { throw new McpError(ErrorCode.InvalidParams, `Prompt ${name} not found. Available prompts: ${prompts.map((p) => p.name).join(', ')}`); } if (!prompt.ajvValidate(args)) { throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(prompt.ajvValidate.errors)}`); } return { description: prompt.description, messages: [ { role: 'user', content: { type: 'text', text: prompt.render(args || {}), }, }, ], }; }); } setupToolHandlers() { /** * Handles the request to list tools. * @param {object} request - The request object. * @returns {object} - The response object containing the tools. */ this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool.tool)); return { tools }; }); /** * 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, extra) => { // eslint-disable-next-line prefer-const let { name, arguments: args, _meta: meta } = request.params; const { progressToken } = meta || {}; const apifyToken = (request.params.apifyToken || process.env.APIFY_TOKEN); const userRentedActorIds = request.params.userRentedActorIds; // 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) { const msg = 'APIFY_TOKEN is required. It must be set in the environment variables or passed as a parameter in the body.'; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError(ErrorCode.InvalidParams, msg); } // Claude is saving tool names with 'local__' prefix, name is local__apify-actors__compass-slash-crawler-google-places // We are interested in the Actor name only, so we remove the 'local__apify-actors__' prefix if (name.startsWith('local__')) { // we split the name by '__' and take the last part, which is the actual Actor name const parts = name.split('__'); log.debug('Tool name with prefix detected', { toolName: name, lastPart: parts[parts.length - 1] }); if (parts.length > 1) { name = parts[parts.length - 1]; } } // TODO - if connection is /mcp client will not receive notification on tool change // Find tool by name or actor full name const tool = Array.from(this.tools.values()) .find((t) => t.tool.name === name || (t.type === 'actor' && t.tool.actorFullName === name)); if (!tool) { const msg = `Tool ${name} not found. Available tools: ${this.listToolNames().join(', ')}`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError(ErrorCode.InvalidParams, msg); } if (!args) { const msg = `Missing arguments for tool ${name}`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError(ErrorCode.InvalidParams, msg); } // Decode dot property names in arguments before validation, // since validation expects the original, non-encoded property names. args = decodeDotPropertyNames(args); log.debug('Validate arguments for tool', { toolName: tool.tool.name, input: args }); if (!tool.tool.ajvValidate(args)) { const msg = `Invalid arguments for tool ${tool.tool.name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(tool === null || tool === void 0 ? void 0 : tool.tool.ajvValidate.errors)}`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError(ErrorCode.InvalidParams, msg); } try { // Handle internal tool if (tool.type === 'internal') { const internalTool = tool.tool; // Only create progress tracker for call-actor tool const progressTracker = internalTool.name === 'call-actor' ? createProgressTracker(progressToken, extra.sendNotification) : null; log.info('Calling internal tool', { name: internalTool.name, input: args }); const res = await internalTool.call({ args, extra, apifyMcpServer: this, mcpServer: this.server, apifyToken, userRentedActorIds, progressTracker, }); if (progressTracker) { progressTracker.stop(); } return { ...res }; } if (tool.type === 'actor-mcp') { const serverTool = tool.tool; let client; try { client = await connectMCPClient(serverTool.serverUrl, apifyToken); // Only set up notification handlers if progressToken is provided by the client if (progressToken) { // Set up notification handlers for the client for (const schema of ServerNotificationSchema.options) { const method = schema.shape.method.value; // Forward notifications from the proxy client to the server client.setNotificationHandler(schema, async (notification) => { log.debug('Sending MCP notification', { method, notification, }); await extra.sendNotification(notification); }); } } log.info('Calling Actor-MCP', { actorId: serverTool.actorId, toolName: serverTool.originToolName, input: args }); const res = await client.callTool({ name: serverTool.originToolName, arguments: args, _meta: { progressToken, }, }, CallToolResultSchema, { timeout: EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, }); return { ...res }; } finally { if (client) await client.close(); } } // Handle actor tool if (tool.type === 'actor') { const actorTool = tool.tool; // Create progress tracker if progressToken is available const progressTracker = createProgressTracker(progressToken, extra.sendNotification); const callOptions = { memory: actorTool.memoryMbytes }; try { log.info('Calling Actor', { actorName: actorTool.actorFullName, input: args }); const { runId, datasetId, items } = await callActorGetDataset(actorTool.actorFullName, args, apifyToken, callOptions, progressTracker); const content = [ { type: 'text', text: `Actor finished with runId: ${runId}, datasetId ${datasetId}` }, ]; const itemContents = items.items.map((item) => { return { type: 'text', text: JSON.stringify(item) }; }); content.push(...itemContents); return { content }; } finally { if (progressTracker) { progressTracker.stop(); } } } } catch (error) { if (error instanceof ApifyApiError) { log.error('Apify API error calling tool', { toolName: name, error }); return { content: [ { type: 'text', text: `Apify API error calling tool ${name}: ${error.message}` }, ], }; } log.error('Error calling tool', { toolName: name, error }); throw new McpError(ErrorCode.InternalError, `An error occurred while calling the tool.`); } const msg = `Unknown tool: ${name}`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg, }); throw new McpError(ErrorCode.InvalidParams, msg); }); } async connect(transport) { await this.server.connect(transport); } async close() { // 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.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(); } } //# sourceMappingURL=server.js.map