diff --git a/Dockerfile.smithery b/Dockerfile.smithery new file mode 100644 index 00000000..6c93bdea --- /dev/null +++ b/Dockerfile.smithery @@ -0,0 +1,30 @@ +FROM node:24-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ + +# Install all dependencies (including devDependencies for build) +RUN npm ci --ignore-scripts + +COPY src ./src +COPY tsconfig.json ./ + +RUN npm run build + +FROM node:24-alpine AS release + +WORKDIR /app + +# Copy only the necessary files from the build stage +COPY --from=builder /app/dist ./dist +COPY package.json package-lock.json ./ + +ENV NODE_ENV=production +ENV APIFY_TOKEN=your-api-key-here + +# Install production dependencies only +RUN npm ci --ignore-scripts --omit=dev + +# Set the entry point for the container +ENTRYPOINT ["node", "dist/smithery.js"] diff --git a/docs/smithery.md b/docs/smithery.md new file mode 100644 index 00000000..04d6ecd8 --- /dev/null +++ b/docs/smithery.md @@ -0,0 +1,26 @@ +# Smithery integration + +- The Smithery entrypoint is `src/smithery.ts`. +- It exports `configSchema` and a default sync function returning the MCP server instance. +- On startup, if `apifyToken`/`APIFY_TOKEN` is provided, tools load asynchronously and the first `listTools` is gated via a one-time barrier (`blockListToolsUntil`). +- If no token is provided, tools are loaded with placeholder token `PLACEHOLDER_TOKEN` to allow the server to start without real secrets. + +Run with Smithery: + +```bash +npx @smithery/cli build +# optional, recommended for actors +export APIFY_TOKEN="your-apify-token" +npx @smithery/cli dev +``` + +Notes: +- The barrier is used only by Smithery; stdio/SSE/HTTP flows are unaffected. +- We use a placeholder token (`your-apify-token`) in non-interactive environments (Smithery scans) to allow tool-loading paths to run without real secrets. It does not grant access; when detected, the client runs unauthenticated to let the server start and list tools where possible. + +## Deployment + +- Publishing to Smithery uses a personal account due to group account functionality issues. +- Publication happens through a repository fork at https://github.com/apify-projects/apify-mcp-server since Smithery requires repository write access, which cannot be granted to the main Apify account (https://github.com/apify/apify-mcp-server). +- The fork requires manual synchronization to stay current, as automatic syncing is not configured. +- As of August 22nd, Smithery supports external server publishing with the requirement of an `/mcp` endpoint. diff --git a/package.json b/package.json index 095a1be2..5b4a3bfa 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "node": ">=18.0.0" }, "main": "dist/index.js", + "module": "./src/smithery.ts", "exports": { ".": "./dist/index.js", "./internals": "./dist/index-internals.js", diff --git a/smithery.yaml b/smithery.yaml index 01616594..5c1b681e 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -1,17 +1,6 @@ -# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml - +runtime: "container" +build: + dockerfile: "Dockerfile.smithery" + dockerBuildPath: "." startCommand: - type: stdio - configSchema: - # JSON Schema defining the configuration options for the MCP. - type: object - required: - - apifyToken - properties: - apifyToken: - type: string - description: The API token for accessing Apify's services. - commandFunction: - # A function that produces the CLI command to start the MCP on stdio. - |- - (config) => ({ command: 'node', args: ['dist/main.js'], env: { APIFY_TOKEN: config.apifyToken } }) \ No newline at end of file + type: "stdio" \ No newline at end of file diff --git a/src/apify-client.ts b/src/apify-client.ts index 026ba79d..84ec8fe3 100644 --- a/src/apify-client.ts +++ b/src/apify-client.ts @@ -2,7 +2,7 @@ import type { ApifyClientOptions } from 'apify'; import { ApifyClient as _ApifyClient } from 'apify-client'; import type { AxiosRequestConfig } from 'axios'; -import { USER_AGENT_ORIGIN } from './const.js'; +import { PLACEHOLDER_APIFY_TOKEN, USER_AGENT_ORIGIN } from './const.js'; /** * Adds a User-Agent header to the request config. @@ -25,12 +25,14 @@ export function getApifyAPIBaseUrl(): string { export class ApifyClient extends _ApifyClient { constructor(options: ApifyClientOptions) { /** - * In order to publish to DockerHub, we need to run their build task to validate our MCP server. - * This was failing since we were sending this dummy token to Apify in order to build the Actor tools. - * So if we encounter this dummy value, we remove it to use Apify client as unauthenticated, which is sufficient - * for server start and listing of tools. + * Placeholder token handling (Smithery/Docker Hub). + * + * We use a placeholder token to allow non-interactive environments (Smithery scans, + * Docker Hub builds) to traverse tool-loading paths without real secrets. The placeholder + * does not authorize any API calls. If detected here, we drop it and run unauthenticated, + * which is enough for server startup and listing tools (where possible). */ - if (options.token?.toLowerCase() === 'your-apify-token') { + if (options.token?.toLowerCase() === PLACEHOLDER_APIFY_TOKEN) { // eslint-disable-next-line no-param-reassign delete options.token; } diff --git a/src/const.ts b/src/const.ts index 2e6797c9..2c647660 100644 --- a/src/const.ts +++ b/src/const.ts @@ -80,3 +80,13 @@ export const ALGOLIA = { export const PROGRESS_NOTIFICATION_INTERVAL_MS = 5_000; // 5 seconds export const APIFY_STORE_URL = 'https://apify.com'; + +/** + * Placeholder token used during non-interactive environments (e.g., Docker Hub builds, Smithery scans) + * to allow tool-loading code paths to execute without failing hard when a real APIFY_TOKEN is unavailable. + * + * IMPORTANT: This token does not authorize any API calls. It is only a sentinel to: + * - Unblock Smithery's initial tool scan so tools can be listed after deployment + * - Avoid crashes in Docker Hub image builds where secrets are not present + */ +export const PLACEHOLDER_APIFY_TOKEN = 'your-apify-token'; diff --git a/src/index.ts b/src/index.ts index 555d3645..c63752b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,6 @@ +#!/usr/bin/env node /* This file provides essential functions and tools for MCP servers, serving as a library. The ActorsMcpServer should be the only class exported from the package */ - -import { ActorsMcpServer } from './mcp/server.js'; - -export { ActorsMcpServer }; +export { ActorsMcpServer } from './mcp/server.js'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 36c9f50c..8f746cb0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -51,6 +51,11 @@ export class ActorsMcpServer { private options: ActorsMcpServerOptions; private toolsChangedHandler: ToolsChangedHandler | undefined; private sigintHandler: (() => Promise) | undefined; + // Barrier to gate the first listTools until initial load settles. + // NOTE: This mechanism is intended to be used ONLY by the Smithery entrypoint + // (see src/smithery.ts). Other server entrypoints (stdio/SSE/HTTP) do not + // use this and are unaffected. + private listToolsBarrier: Promise | null = null; constructor(options: ActorsMcpServerOptions = {}, setupSigintHandler = true) { this.options = { @@ -89,6 +94,20 @@ export class ActorsMcpServer { }); } + /** + * Block the first listTools request until the provided promise settles or a timeout elapses. + * Subsequent listTools calls are not blocked unless this method is invoked again. + * + * This is used exclusively by the Smithery entrypoint to satisfy its synchronous + * factory requirement while ensuring initial tools are available on the first + * listTools call. Other entrypoints should not rely on this. + */ + public blockListToolsUntil(promise: Promise, timeoutMs = 8_000) { + const done = Promise.resolve(promise).then(() => undefined).catch(() => undefined); + const timeout = new Promise((resolve) => { setTimeout(resolve, timeoutMs); }); + this.listToolsBarrier = Promise.race([done, timeout]).then(() => undefined); + } + /** * Returns an array of tool names. * @returns {string[]} - An array of tool names. @@ -391,6 +410,10 @@ export class ActorsMcpServer { * @returns {object} - The response object containing the tools. */ this.server.setRequestHandler(ListToolsRequestSchema, async () => { + if (this.listToolsBarrier) { + await this.listToolsBarrier; + this.listToolsBarrier = null; + } const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool.tool)); return { tools }; }); diff --git a/src/smithery.ts b/src/smithery.ts new file mode 100644 index 00000000..afaa1d69 --- /dev/null +++ b/src/smithery.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/* + This file serves as the main entry point for smithery deployment. +*/ +import type { z } from 'zod'; + +import { PLACEHOLDER_APIFY_TOKEN } from './const.js'; +import { ActorsMcpServer } from './mcp/server.js'; +import type { Input, ToolCategory } from './types'; +import { serverConfigSchemaSmithery as configSchema } from './types.js'; +import { loadToolsFromInput } from './utils/tools-loader.js'; + +// Export the config schema for Smithery. The export must be named configSchema +export { configSchema }; + +/** + * The Main entrypoint for Smithery deployment do not change signature of this function. + * @returns + */ +// eslint-disable-next-line import/no-default-export +export default function ({ config: _config }: { config: z.infer }) { + try { + const apifyToken = _config.apifyToken || process.env.APIFY_TOKEN || ''; + const enableAddingActors = _config.enableAddingActors ?? true; + const actors = _config.actors || ''; + const actorList = actors ? actors.split(',').map((a: string) => a.trim()) : []; + const toolCategoryKeys = _config.tools ? _config.tools.split(',').map((t: string) => t.trim()) : []; + + process.env.APIFY_TOKEN = apifyToken; // Ensure token is set in the environment + const server = new ActorsMcpServer({ enableAddingActors, enableDefaultActors: false }); + + const input: Input = { + actors: actorList.length ? actorList : [], + enableAddingActors, + tools: toolCategoryKeys as ToolCategory[], + }; + + // Load tools asynchronously and block initial listTools call (Smithery-specific behavior) + // Refer to docs/smithery.md for details on how this entrypoint integrates with Smithery + // Smithery uses an unknown token during deployment, requiring this fallback approach + const loadPromise = (async () => { + try { + const tools = await loadToolsFromInput(input, apifyToken, actorList.length === 0); + server.upsertTools(tools); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load tools with provided token. Retrying with placeholder token, error', error); + try { + const tools = await loadToolsFromInput(input, PLACEHOLDER_APIFY_TOKEN, actorList.length === 0); + server.upsertTools(tools); + } catch (retryError) { + // eslint-disable-next-line no-console + console.error('Retry failed to load tools with placeholder token, error:', retryError); + } + } + })(); + server.blockListToolsUntil(loadPromise); + return server.server; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + throw e; + } +} diff --git a/src/stdio.ts b/src/stdio.ts index a4a52c02..1b9443d0 100644 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -24,23 +24,9 @@ import log from '@apify/log'; import { ActorsMcpServer } from './mcp/server.js'; import { toolCategories } from './tools/index.js'; -import type { Input, ToolCategory } from './types.js'; +import type { Input, ServerConfigCli, ToolCategory } from './types.js'; import { loadToolsFromInput } from './utils/tools-loader.js'; -// Keeping this interface here and not types.ts since -// it is only relevant to the CLI/STDIO transport in this file -/** - * Interface for command line arguments - */ -interface CliArgs { - actors?: string; - enableAddingActors: boolean; - /** @deprecated */ - enableActorAutoLoading: boolean; - /** Tool categories to include */ - tools?: string; -} - // Configure logging, set to ERROR log.setLevel(log.LEVELS.ERROR); @@ -88,7 +74,7 @@ Note: Tools that enable you to search Actors from the Apify Store and get their + ' and set the environment variable `APIFY_TOKEN` to your Apify API token.\n', ) .epilogue('For more information, visit https://mcp.apify.com or https://github.com/apify/apify-mcp-server') - .parseSync() as CliArgs; + .parseSync() as ServerConfigCli; const enableAddingActors = argv.enableAddingActors && argv.enableActorAutoLoading; const actors = argv.actors as string || ''; diff --git a/src/types.ts b/src/types.ts index 497cd515..d2a70ca7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,12 +3,41 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/proto import type { Notification, Prompt, Request } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; import type { ActorDefaultRunOptions, ActorDefinition, ActorStoreList, PricingInfo } from 'apify-client'; +import { z } from 'zod'; import type { ACTOR_PRICING_MODEL } from './const.js'; import type { ActorsMcpServer } from './mcp/server.js'; import type { toolCategories } from './tools/index.js'; import type { ProgressTracker } from './utils/progress.js'; +export const serverConfigSchemaCli = z.object({ + actors: z + .string() + .optional() + .describe('Comma-separated list of Actor full names to add to the server'), + enableAddingActors: z + .boolean() + .default(true) + .describe('Enable dynamically adding Actors as tools based on user requests'), + tools: z + .string() + .optional() + .describe('Comma-separated list of specific tool categories to enable (docs,runs,storage,preview)'), + /** @deprecated */ + enableActorAutoLoading: z + .boolean() + .default(true) + .describe('Deprecated: use enable-adding-actors instead.'), +}); + +export const serverConfigSchemaSmithery = serverConfigSchemaCli.extend({ + apifyToken: z + .string() + .describe('Apify token, learn more: https://docs.apify.com/platform/integrations/api#api-token'), +}); + +export type ServerConfigCli = z.infer; + export interface ISchemaProperties { type: string; diff --git a/tests/unit/smithery.test.ts b/tests/unit/smithery.test.ts new file mode 100644 index 00000000..3deba187 --- /dev/null +++ b/tests/unit/smithery.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import log from '@apify/log'; + +import { ActorsMcpServer } from '../../src/mcp/server.js'; +import smithery from '../../src/smithery.js'; +import * as toolsLoader from '../../src/utils/tools-loader.js'; + +// Silence logs in unit tests +log.setLevel(log.LEVELS.OFF); + +describe('smithery entrypoint barrier behavior', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('calls blockListToolsUntil', async () => { + // Arrange + const blockSpy = vi.spyOn(ActorsMcpServer.prototype, 'blockListToolsUntil'); + const loadSpy = vi.spyOn(toolsLoader, 'loadToolsFromInput').mockResolvedValue([]); + + // Act + const server = smithery({ config: { apifyToken: 'TEST_TOKEN', enableAddingActors: true, enableActorAutoLoading: true } }); + + // Assert + expect(server).toBeTruthy(); + expect(blockSpy).toHaveBeenCalledTimes(1); + expect(loadSpy).toHaveBeenCalledTimes(1); + }); +});