Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Dockerfile.smithery
Original file line number Diff line number Diff line change
@@ -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"]
19 changes: 19 additions & 0 deletions docs/smithery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"node": ">=18.0.0"
},
"main": "dist/index.js",
"module": "./src/smithery.ts",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info I get from LLM how to use main and module:

- main should point to the CommonJS build (usually in dist/).
- module should point to the ESM build (also in dist/), not to the TypeScript source.
- Both should export the same API.
- Ensure all referenced files are published.

The problem is that we have other exported APIs in the main and module. I think it will break our internal server if Typescript looks at the module and sees only Smithery, not ActorsMcpServer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry for the late answer, as smithery deployment was deprioritized.
I'll fix it based on your comment.

Copy link
Collaborator Author

@jirispilka jirispilka Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not work at smithery, when I use:

    "main": "dist/index.js",
    "module": "dist/index.js", 

#15 6.878 ❌ Build failed: Error: Entry file specified in package.json not found at /app/dist/index.js.

Because smithery is not building the project 🤷🏻

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If some settings in Smithery are possible (like changing the root directory), we can create a directory with the package only for Smithery.

It looks like Smithery requirements are not compatible with normal package usage.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to deploy remote server, if possible

"exports": {
".": "./dist/index.js",
"./internals": "./dist/index-internals.js",
Expand Down
21 changes: 5 additions & 16 deletions smithery.yaml
Original file line number Diff line number Diff line change
@@ -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 } })
type: "stdio"
14 changes: 8 additions & 6 deletions src/apify-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 2 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
23 changes: 23 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export class ActorsMcpServer {
private options: ActorsMcpServerOptions;
private toolsChangedHandler: ToolsChangedHandler | undefined;
private sigintHandler: (() => Promise<void>) | 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<void> | null = null;

constructor(options: ActorsMcpServerOptions = {}, setupSigintHandler = true) {
this.options = {
Expand Down Expand Up @@ -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<unknown>, timeoutMs = 8_000) {
const done = Promise.resolve(promise).then(() => undefined).catch(() => undefined);
const timeout = new Promise<void>((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.
Expand Down Expand Up @@ -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 };
});
Expand Down
66 changes: 66 additions & 0 deletions src/smithery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/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
Also, it 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<typeof configSchema> }) {
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[],
};

// Start async tools loading and gate the first listTools (Smithery-only)
// See docs/smithery.md for a brief overview of how this entrypoint works with Smithery
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;
}
}
18 changes: 2 additions & 16 deletions src/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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/actors-mcp-server')
.parseSync() as CliArgs;
.parseSync() as ServerConfigCli;

const enableAddingActors = argv.enableAddingActors && argv.enableActorAutoLoading;
const actors = argv.actors as string || '';
Expand Down
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof serverConfigSchemaCli>;

export interface ISchemaProperties {
type: string;

Expand Down
30 changes: 30 additions & 0 deletions tests/unit/smithery.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});