Skip to content

Commit 4cd7c4a

Browse files
committed
fix: smithery
1 parent 998cb90 commit 4cd7c4a

File tree

6 files changed

+112
-27
lines changed

6 files changed

+112
-27
lines changed

docs/smithery.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Smithery integration
2+
3+
- The Smithery entrypoint is `src/smithery.ts`.
4+
- It exports `configSchema` and a default sync function returning the MCP server instance.
5+
- On startup, if `apifyToken`/`APIFY_TOKEN` is provided, tools load asynchronously and the first `listTools` is gated via a one-time barrier (`blockListToolsUntil`).
6+
- If no token is provided, tools are loaded with placeholder token `PLACEHOLDER_TOKEN` to allow the server to start without real secrets.
7+
8+
Run with Smithery:
9+
10+
```bash
11+
npx @smithery/cli build
12+
# optional, recommended for actors
13+
export APIFY_TOKEN="your-apify-token"
14+
npx @smithery/cli dev
15+
```
16+
17+
Notes:
18+
- The barrier is used only by Smithery; stdio/SSE/HTTP flows are unaffected.
19+
- 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.

src/apify-client.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ApifyClientOptions } from 'apify';
22
import { ApifyClient as _ApifyClient } from 'apify-client';
33
import type { AxiosRequestConfig } from 'axios';
44

5-
import { USER_AGENT_ORIGIN } from './const.js';
5+
import { PLACEHOLDER_APIFY_TOKEN, USER_AGENT_ORIGIN } from './const.js';
66

77
/**
88
* Adds a User-Agent header to the request config.
@@ -25,12 +25,14 @@ export function getApifyAPIBaseUrl(): string {
2525
export class ApifyClient extends _ApifyClient {
2626
constructor(options: ApifyClientOptions) {
2727
/**
28-
* In order to publish to DockerHub, we need to run their build task to validate our MCP server.
29-
* This was failing since we were sending this dummy token to Apify in order to build the Actor tools.
30-
* So if we encounter this dummy value, we remove it to use Apify client as unauthenticated, which is sufficient
31-
* for server start and listing of tools.
28+
* Placeholder token handling (Smithery/Docker Hub).
29+
*
30+
* We use a placeholder token to allow non-interactive environments (Smithery scans,
31+
* Docker Hub builds) to traverse tool-loading paths without real secrets. The placeholder
32+
* does not authorize any API calls. If detected here, we drop it and run unauthenticated,
33+
* which is enough for server startup and listing tools (where possible).
3234
*/
33-
if (options.token?.toLowerCase() === 'your-apify-token') {
35+
if (options.token?.toLowerCase() === PLACEHOLDER_APIFY_TOKEN) {
3436
// eslint-disable-next-line no-param-reassign
3537
delete options.token;
3638
}

src/const.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,13 @@ export const ALGOLIA = {
8080
export const PROGRESS_NOTIFICATION_INTERVAL_MS = 5_000; // 5 seconds
8181

8282
export const APIFY_STORE_URL = 'https://apify.com';
83+
84+
/**
85+
* Placeholder token used during non-interactive environments (e.g., Docker Hub builds, Smithery scans)
86+
* to allow tool-loading code paths to execute without failing hard when a real APIFY_TOKEN is unavailable.
87+
*
88+
* IMPORTANT: This token does not authorize any API calls. It is only a sentinel to:
89+
* - Unblock Smithery's initial tool scan so tools can be listed after deployment
90+
* - Avoid crashes in Docker Hub image builds where secrets are not present
91+
*/
92+
export const PLACEHOLDER_APIFY_TOKEN = 'your-apify-token';

src/mcp/server.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ export class ActorsMcpServer {
5151
private options: ActorsMcpServerOptions;
5252
private toolsChangedHandler: ToolsChangedHandler | undefined;
5353
private sigintHandler: (() => Promise<void>) | undefined;
54+
// Barrier to gate the first listTools until initial load settles.
55+
// NOTE: This mechanism is intended to be used ONLY by the Smithery entrypoint
56+
// (see src/smithery.ts). Other server entrypoints (stdio/SSE/HTTP) do not
57+
// use this and are unaffected.
58+
private listToolsBarrier: Promise<void> | null = null;
5459

5560
constructor(options: ActorsMcpServerOptions = {}, setupSigintHandler = true) {
5661
this.options = {
@@ -89,6 +94,20 @@ export class ActorsMcpServer {
8994
});
9095
}
9196

97+
/**
98+
* Block the first listTools request until the provided promise settles or a timeout elapses.
99+
* Subsequent listTools calls are not blocked unless this method is invoked again.
100+
*
101+
* This is used exclusively by the Smithery entrypoint to satisfy its synchronous
102+
* factory requirement while ensuring initial tools are available on the first
103+
* listTools call. Other entrypoints should not rely on this.
104+
*/
105+
public blockListToolsUntil(promise: Promise<unknown>, timeoutMs = 8_000) {
106+
const done = Promise.resolve(promise).then(() => undefined).catch(() => undefined);
107+
const timeout = new Promise<void>((resolve) => setTimeout(resolve, timeoutMs));
108+
this.listToolsBarrier = Promise.race([done, timeout]).then(() => undefined);
109+
}
110+
92111
/**
93112
* Returns an array of tool names.
94113
* @returns {string[]} - An array of tool names.
@@ -391,6 +410,10 @@ export class ActorsMcpServer {
391410
* @returns {object} - The response object containing the tools.
392411
*/
393412
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
413+
if (this.listToolsBarrier) {
414+
await this.listToolsBarrier;
415+
this.listToolsBarrier = null;
416+
}
394417
const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool.tool));
395418
return { tools };
396419
});

src/smithery.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ActorsMcpServer } from './mcp/server.js';
1111
import type { Input, ToolCategory } from './types';
1212
import { serverConfigSchemaSmithery as configSchema } from './types.js';
1313
import { loadToolsFromInput } from './utils/tools-loader.js';
14+
import { PLACEHOLDER_APIFY_TOKEN } from './const.js';
1415

1516
// Export the config schema for Smithery. The export must be named configSchema
1617
export { configSchema };
@@ -21,20 +22,14 @@ export { configSchema };
2122
*/
2223
export default function ({ config: _config }: { config: z.infer<typeof configSchema> }) {
2324
try {
24-
const apifyToken = _config.apifyToken || process.env.APIFY_TOKEN || '';
25+
let apifyToken = _config.apifyToken || process.env.APIFY_TOKEN || '';
2526
const enableAddingActors = _config.enableAddingActors ?? true;
2627
const actors = _config.actors || '';
2728
const actorList = actors ? actors.split(',').map((a: string) => a.trim()) : [];
2829
const toolCategoryKeys = _config.tools ? _config.tools.split(',').map((t: string) => t.trim()) : [];
2930

30-
// Validate environment
31-
if (!apifyToken) {
32-
// eslint-disable-next-line no-console
33-
console.warn('APIFY_TOKEN is required but not set in the environment variables or config. Some tools may not work properly.');
34-
} else {
35-
process.env.APIFY_TOKEN = apifyToken; // Ensure token is set in the environment
36-
}
37-
31+
console.log(`Apify token ${apifyToken}`)
32+
process.env.APIFY_TOKEN = apifyToken; // Ensure token is set in the environment
3833
const server = new ActorsMcpServer({ enableAddingActors, enableDefaultActors: false });
3934

4035
const input: Input = {
@@ -43,21 +38,27 @@ export default function ({ config: _config }: { config: z.infer<typeof configSch
4338
tools: toolCategoryKeys as ToolCategory[],
4439
};
4540

46-
// NOTE: This is a workaround for Smithery's requirement of a synchronous function
47-
// We load tools asynchronously and attach the promise to the server
48-
// However, this approach is NOT 99% reliable - the external library may still
49-
// try to use the server before tools are fully loaded
50-
loadToolsFromInput(input, apifyToken, actorList.length === -1)
51-
.then((tools) => {
41+
// Start async tools loading and gate the first listTools (Smithery-only)
42+
// See docs/smithery.md for a brief overview of how this entrypoint works with Smithery
43+
const loadPromise = (async () => {
44+
try {
45+
const tools = await loadToolsFromInput(input, apifyToken, actorList.length === 0);
5246
server.upsertTools(tools);
53-
return true;
54-
})
55-
.catch((error) => {
47+
} catch (error) {
5648
// eslint-disable-next-line no-console
57-
console.error('Failed to load tools:', error);
58-
return false;
59-
});
49+
console.error('Failed to load tools with provided token. Retrying with placeholder token, error', error);
50+
try {
51+
const tools = await loadToolsFromInput(input, PLACEHOLDER_APIFY_TOKEN, actorList.length === 0);
52+
server.upsertTools(tools);
53+
} catch (retryError) {
54+
// eslint-disable-next-line no-console
55+
console.error('Retry failed to load tools with placeholder token, error:', retryError);
56+
}
57+
}
58+
})();
59+
server.blockListToolsUntil(loadPromise);
6060
return server.server;
61+
6162
} catch (e) {
6263
// eslint-disable-next-line no-console
6364
console.error(e);

tests/unit/smithery.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import log from '@apify/log';
4+
5+
import * as toolsLoader from '../../src/utils/tools-loader.js';
6+
import { ActorsMcpServer } from '../../src/mcp/server.js';
7+
import smithery from '../../src/smithery.js';
8+
9+
// Silence logs in unit tests
10+
log.setLevel(log.LEVELS.OFF);
11+
12+
describe('smithery entrypoint barrier behavior', () => {
13+
beforeEach(() => {
14+
vi.restoreAllMocks();
15+
});
16+
17+
it('calls blockListToolsUntil', async () => {
18+
// Arrange
19+
const blockSpy = vi.spyOn(ActorsMcpServer.prototype as any, 'blockListToolsUntil');
20+
const loadSpy = vi.spyOn(toolsLoader, 'loadToolsFromInput').mockResolvedValue([]);
21+
22+
// Act
23+
const server = smithery({ config: { apifyToken: 'TEST_TOKEN', enableAddingActors: true } as any });
24+
25+
// Assert
26+
expect(server).toBeTruthy();
27+
expect(blockSpy).toHaveBeenCalledTimes(1);
28+
expect(loadSpy).toHaveBeenCalledTimes(1);
29+
});
30+
});

0 commit comments

Comments
 (0)