Skip to content

Commit cd5889c

Browse files
committed
fix: Honor AbortSignal on Actor-MCP calls and hash-dedupe proxy names
Remote Actor-MCP callTool ignored cancel/disconnect, and bare truncation could collide over-length proxied tool names.
1 parent fbeadb3 commit cd5889c

7 files changed

Lines changed: 210 additions & 14 deletions

File tree

src/mcp/AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Two MCP protocol revisions are served, each by its own adapter:
3434
- `tool_call_engine.ts` — shared `tools/call` orchestration. `prepareToolCall()` handles
3535
preparation; `executeSyncToolCall()` runs synchronous calls.
3636
- `client.ts``connectMCPClient(url, token)`: transport negotiation.
37-
- `proxy.ts` — MCP-in-MCP: `getMCPServerID(url)`.
37+
- `proxy.ts` — MCP-in-MCP: `getMCPServerID(url)`, `getProxyMCPServerToolName(url, toolName)`.
3838
- `actors.ts``getActorMCPServerPath()`: parses an Actor's `webServerMcpPath`.
3939
- `utils.ts``processParamsGetTools()`: turns `?actors=` URL params into tools.
4040
- `tool_call_error_mapper.ts` — shared tool-call error classification.
@@ -61,8 +61,9 @@ Two MCP protocol revisions are served, each by its own adapter:
6161
each other.
6262
- **Tool names: capped + hash-deduped.** Names are capped at `MAX_TOOL_NAME_LENGTH`;
6363
over-length or colliding names get a `TOOL_NAME_HASH_LENGTH` hash suffix so the
64-
exposed set stays unique within the limit (the hashing is in `../tools/actor_tool_naming.ts`).
65-
Never widen the cap — downstream clients depend on it.
64+
exposed set stays unique within the limit (Actor tools: `../tools/actor_tool_naming.ts`;
65+
proxied Actor-MCP tools: `proxy.ts` `getProxyMCPServerToolName`). Never widen the
66+
cap — downstream clients depend on it.
6667
- **Proxy server IDs are keyed by URL, not Actor ID.** `getMCPServerID(url)` is
6768
`sha256(url)` sliced to `SERVER_ID_LENGTH`. One Actor can expose both an SSE and a
6869
streamable endpoint; keying by URL keeps those distinct. Keying by Actor ID would

src/mcp/proxy.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { fixedAjvCompile } from '../tools/actor_input_schema.js';
66
import type { ActorMcpTool, ToolEntry } from '../types.js';
77
import { TOOL_TYPE } from '../types.js';
88
import { ajv } from '../utils/ajv.js';
9-
import { MAX_TOOL_NAME_LENGTH, SERVER_ID_LENGTH } from './const.js';
9+
import { MAX_TOOL_NAME_LENGTH, SERVER_ID_LENGTH, TOOL_NAME_HASH_LENGTH } from './const.js';
1010

1111
/**
1212
* Generates a unique server ID by hashing the URL.
@@ -20,14 +20,20 @@ export function getMCPServerID(url: string): string {
2020
}
2121

2222
/**
23-
* Prefixes the tool name with the server ID hash and truncates to MAX_TOOL_NAME_LENGTH.
24-
* Truncation can in theory collide two different origin tool names.
23+
* Prefixes the tool name with the server ID hash. Over-length names get a hash suffix
24+
* (same pattern as actor tool names) so bare truncation cannot collide two different
25+
* origin tool names into one exposed name.
2526
*/
26-
function getProxyMCPServerToolName(url: string, toolName: string): string {
27+
export function getProxyMCPServerToolName(url: string, toolName: string): string {
2728
const prefix = getMCPServerID(url);
28-
2929
const fullName = `${prefix}-${toolName}`;
30-
return fullName.slice(0, MAX_TOOL_NAME_LENGTH);
30+
31+
if (fullName.length <= MAX_TOOL_NAME_LENGTH) {
32+
return fullName;
33+
}
34+
35+
const hash = createHash('sha256').update(fullName).digest('hex').slice(0, TOOL_NAME_HASH_LENGTH);
36+
return `${fullName.slice(0, MAX_TOOL_NAME_LENGTH - TOOL_NAME_HASH_LENGTH - 1)}-${hash}`;
3137
}
3238

3339
export async function getMCPServerTools(actorID: string, client: Client, serverUrl: string): Promise<ToolEntry[]> {

src/mcp/tool_dispatch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ export async function dispatchToolCall(params: {
181181
CallToolResultSchema,
182182
{
183183
timeout: EXTERNAL_TOOL_CALL_TIMEOUT_MSEC,
184+
// Same abort source as ACTOR/INTERNAL branches: request signal for sync,
185+
// cancel-watcher signal for tasks. Without this, cancel/disconnect cannot
186+
// stop the remote call before EXTERNAL_TOOL_CALL_TIMEOUT_MSEC.
187+
signal,
184188
},
185189
);
186190

src/tools/actors/call_actor.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
22
import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js';
3+
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
34
import dedent from 'dedent';
45
import { z } from 'zod';
56

@@ -15,6 +16,7 @@ import {
1516
} from '../../const.js';
1617
import { ACTOR_LOAD_ERROR_KIND, ActorLoadError } from '../../errors.js';
1718
import { connectMCPClient } from '../../mcp/client.js';
19+
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC } from '../../mcp/const.js';
1820
import type { PaymentProvider } from '../../payments/types.js';
1921
import type { ActorInfo, ApifyToken, InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
2022
import { TOOL_TYPE } from '../../types.js';
@@ -324,8 +326,10 @@ export async function handleMcpToolCall(params: {
324326
mcpServerUrl: string | false;
325327
apifyToken: string;
326328
mcpSessionId?: string;
329+
signal: AbortSignal;
327330
}): Promise<ToolResponse | null> {
328-
const { baseActorName, mcpToolName, input, isActorMcpServer, mcpServerUrl, apifyToken, mcpSessionId } = params;
331+
const { baseActorName, mcpToolName, input, isActorMcpServer, mcpServerUrl, apifyToken, mcpSessionId, signal } =
332+
params;
329333

330334
if (!isActorMcpServer) {
331335
return respondServerError(`Actor '${baseActorName}' is not an MCP server.`);
@@ -337,17 +341,32 @@ export async function handleMcpToolCall(params: {
337341
);
338342
}
339343

344+
if (signal.aborted) {
345+
return respondAborted();
346+
}
347+
340348
let client: Client | null = null;
341349
try {
342350
client = await connectMCPClient(mcpServerUrl as string, apifyToken, mcpSessionId);
343351
if (!client) {
344352
return respondServerError(`Failed to connect to MCP server ${mcpServerUrl}`);
345353
}
346354

347-
const result = await client.callTool({
348-
name: mcpToolName,
349-
arguments: input,
350-
});
355+
if (signal.aborted) {
356+
return respondAborted();
357+
}
358+
359+
const result = await client.callTool(
360+
{
361+
name: mcpToolName,
362+
arguments: input,
363+
},
364+
CallToolResultSchema,
365+
{
366+
timeout: EXTERNAL_TOOL_CALL_TIMEOUT_MSEC,
367+
signal,
368+
},
369+
);
351370

352371
// `call-actor` declares `actorRunOutputSchema`, so MCP SDK ≥ 1.11.4 rejects any response
353372
// without `structuredContent` (unless `isError: true`) with -32600. The pass-through has no
@@ -369,6 +388,9 @@ export async function handleMcpToolCall(params: {
369388
},
370389
});
371390
} catch (error) {
391+
if (signal.aborted) {
392+
return respondAborted();
393+
}
372394
logHttpError(error, `Failed to call MCP tool '${mcpToolName}' on Actor '${baseActorName}'`, {
373395
actorName: baseActorName,
374396
toolName: mcpToolName,
@@ -529,6 +551,7 @@ export async function callActorPreExecute(
529551
mcpServerUrl: mcpServerUrlOrFalse,
530552
apifyToken,
531553
mcpSessionId,
554+
signal: toolArgs.signal,
532555
});
533556
if (mcpResult) {
534557
return { earlyResponse: mcpResult };

tests/unit/mcp.proxy.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createHash } from 'node:crypto';
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { MAX_TOOL_NAME_LENGTH, SERVER_ID_LENGTH, TOOL_NAME_HASH_LENGTH } from '../../src/mcp/const.js';
6+
import { getMCPServerID, getProxyMCPServerToolName } from '../../src/mcp/proxy.js';
7+
8+
describe('getMCPServerID()', () => {
9+
it('returns a stable SERVER_ID_LENGTH hex prefix of sha256(url)', () => {
10+
const url = 'https://example.com/mcp';
11+
const expected = createHash('sha256').update(url).digest('hex').slice(0, SERVER_ID_LENGTH);
12+
expect(getMCPServerID(url)).toBe(expected);
13+
expect(getMCPServerID(url)).toBe(getMCPServerID(url));
14+
});
15+
16+
it('keys by URL so SSE and streamable endpoints stay distinct', () => {
17+
expect(getMCPServerID('https://actor.example/sse')).not.toBe(getMCPServerID('https://actor.example/mcp'));
18+
});
19+
});
20+
21+
describe('getProxyMCPServerToolName()', () => {
22+
const url = 'https://example.com/mcp';
23+
24+
it('returns prefix-toolName when under the length cap', () => {
25+
const name = getProxyMCPServerToolName(url, 'list-items');
26+
expect(name).toBe(`${getMCPServerID(url)}-list-items`);
27+
expect(name.length).toBeLessThanOrEqual(MAX_TOOL_NAME_LENGTH);
28+
});
29+
30+
it('hash-suffixes over-length names instead of bare truncation', () => {
31+
const longTool = `very-long-origin-tool-name-${'x'.repeat(80)}`;
32+
const fullName = `${getMCPServerID(url)}-${longTool}`;
33+
const hash = createHash('sha256').update(fullName).digest('hex').slice(0, TOOL_NAME_HASH_LENGTH);
34+
const name = getProxyMCPServerToolName(url, longTool);
35+
36+
expect(name.length).toBe(MAX_TOOL_NAME_LENGTH);
37+
expect(name.endsWith(`-${hash}`)).toBe(true);
38+
// Bare slice would drop the distinguishing suffix and collide; hash must survive.
39+
expect(name).not.toBe(fullName.slice(0, MAX_TOOL_NAME_LENGTH));
40+
});
41+
42+
it('keeps two over-length origin names distinct after capping', () => {
43+
const sharedPrefix = `shared-prefix-${'y'.repeat(80)}`;
44+
const a = getProxyMCPServerToolName(url, `${sharedPrefix}-alpha`);
45+
const b = getProxyMCPServerToolName(url, `${sharedPrefix}-beta`);
46+
47+
expect(a).not.toBe(b);
48+
expect(a.length).toBe(MAX_TOOL_NAME_LENGTH);
49+
expect(b.length).toBe(MAX_TOOL_NAME_LENGTH);
50+
});
51+
});

tests/unit/mcp.server.tool_call_contracts.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,37 @@ describe('ACTOR_MCP remote-McpError containment (sync tools/call catch)', () =>
702702
expect(properties.failure_category).toBe(FAILURE_CATEGORY.INVALID_INPUT);
703703
});
704704

705+
it('forwards the abort signal into the remote Actor-MCP callTool options', async () => {
706+
// Without `signal` in callTool options, cancel/disconnect cannot stop the remote call before
707+
// EXTERNAL_TOOL_CALL_TIMEOUT_MSEC — ACTOR/INTERNAL already abort; ACTOR_MCP must match.
708+
await withServer(async (server) => {
709+
silenceLogs();
710+
const callTool = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] });
711+
const stubClient = {
712+
callTool,
713+
close: vi.fn().mockResolvedValue(undefined),
714+
setNotificationHandler: vi.fn(),
715+
} as unknown as Client;
716+
vi.spyOn(mcpClient, 'connectMCPClient').mockResolvedValue(stubClient);
717+
718+
const controller = new AbortController();
719+
server.upsertTools([makeActorMcpTool()]);
720+
const handler = getRequestHandler(server, 'tools/call');
721+
await handler(
722+
{
723+
method: 'tools/call',
724+
params: { name: 'test-actor-mcp-tool', arguments: {}, _meta: { mcpSessionId: 's1' } },
725+
},
726+
{ signal: controller.signal, sendNotification: vi.fn() },
727+
);
728+
729+
expect(callTool).toHaveBeenCalledTimes(1);
730+
const options = callTool.mock.calls[0][2] as { signal?: AbortSignal; timeout?: number };
731+
expect(options.signal).toBe(controller.signal);
732+
expect(options.timeout).toBeGreaterThan(0);
733+
});
734+
});
735+
705736
it('re-throws an escaped McpError as a JSON-RPC error, not an isError tool result', async () => {
706737
// Escaped McpErrors must remain JSON-RPC errors, including 402-coded errors.
707738
await withServer(async (server) => {

tests/unit/tools.call_actor_common.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
12
import { ApifyApiError } from 'apify-client';
23
import type { AxiosResponse } from 'axios';
34
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,11 +9,14 @@ import {
89
HELPER_TOOLS,
910
TOOL_STATUS,
1011
} from '../../src/const.js';
12+
import * as mcpClient from '../../src/mcp/client.js';
13+
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC } from '../../src/mcp/const.js';
1114
import {
1215
buildCallActorAppsDescription,
1316
buildCallActorDescription,
1417
buildCallActorErrorResponse,
1518
callActorArgs,
19+
handleMcpToolCall,
1620
resolveAndValidateActor,
1721
} from '../../src/tools/actors/call_actor.js';
1822
import type { InternalToolArgs, ToolEntry } from '../../src/types.js';
@@ -331,4 +335,80 @@ describe('call_actor_common', () => {
331335
});
332336
});
333337
});
338+
339+
describe('handleMcpToolCall()', () => {
340+
beforeEach(() => {
341+
vi.restoreAllMocks();
342+
});
343+
344+
it('forwards signal and timeout into client.callTool options', async () => {
345+
const callTool = vi.fn().mockResolvedValue({
346+
content: [{ type: 'text', text: 'remote ok' }],
347+
isError: false,
348+
});
349+
vi.spyOn(mcpClient, 'connectMCPClient').mockResolvedValue({
350+
callTool,
351+
close: vi.fn().mockResolvedValue(undefined),
352+
} as unknown as Client);
353+
354+
const controller = new AbortController();
355+
const result = await handleMcpToolCall({
356+
baseActorName: 'apify/mcp-demo',
357+
mcpToolName: 'search',
358+
input: { q: 'x' },
359+
isActorMcpServer: true,
360+
mcpServerUrl: 'https://example.invalid/mcp',
361+
apifyToken: 'token',
362+
signal: controller.signal,
363+
});
364+
365+
expect(result?.isError).not.toBe(true);
366+
expect(callTool).toHaveBeenCalledTimes(1);
367+
const options = callTool.mock.calls[0][2] as { signal?: AbortSignal; timeout?: number };
368+
expect(options.signal).toBe(controller.signal);
369+
expect(options.timeout).toBe(EXTERNAL_TOOL_CALL_TIMEOUT_MSEC);
370+
});
371+
372+
it('returns aborted when the signal is already aborted before the remote call', async () => {
373+
const connectSpy = vi.spyOn(mcpClient, 'connectMCPClient');
374+
const controller = new AbortController();
375+
controller.abort();
376+
377+
const result = await handleMcpToolCall({
378+
baseActorName: 'apify/mcp-demo',
379+
mcpToolName: 'search',
380+
input: { q: 'x' },
381+
isActorMcpServer: true,
382+
mcpServerUrl: 'https://example.invalid/mcp',
383+
apifyToken: 'token',
384+
signal: controller.signal,
385+
});
386+
387+
expect(result).toEqual({});
388+
expect(connectSpy).not.toHaveBeenCalled();
389+
});
390+
391+
it('returns aborted when the remote call rejects after the signal aborts', async () => {
392+
const controller = new AbortController();
393+
vi.spyOn(mcpClient, 'connectMCPClient').mockResolvedValue({
394+
callTool: vi.fn().mockImplementation(async () => {
395+
controller.abort();
396+
throw new Error('aborted');
397+
}),
398+
close: vi.fn().mockResolvedValue(undefined),
399+
} as unknown as Client);
400+
401+
const result = await handleMcpToolCall({
402+
baseActorName: 'apify/mcp-demo',
403+
mcpToolName: 'search',
404+
input: { q: 'x' },
405+
isActorMcpServer: true,
406+
mcpServerUrl: 'https://example.invalid/mcp',
407+
apifyToken: 'token',
408+
signal: controller.signal,
409+
});
410+
411+
expect(result).toEqual({});
412+
});
413+
});
334414
});

0 commit comments

Comments
 (0)