Skip to content

Commit 9cf3a39

Browse files
authored
fix(call-actor): synthesize structuredContent on MCP server pass-through (#859)
## Context `call-actor` with MCP-server pass-through syntax (`actor: "apify/actors-mcp-server:search-apify-docs"`) fails on master with `MCP error -32600: Tool call-actor has an output schema but did not return structured content`. Repro with `mcpc --json @apify tools-call call-actor actor:='"apify/actors-mcp-server:search-apify-docs"' input:='{"query":"weather"}'`. Regression introduced in #415 (`f9512c4`, 2026-01-29) when `outputSchema` was added to `call-actor`; the MCP-pass-through code in `handleMcpToolCall` has been returning `{ content }` only since #274 (2025-09-18). Both ingredients sat dormant until the SDK on `^1.25.2` (already enforcing since 1.11.4) saw them combined. ## Solution In `handleMcpToolCall`, synthesize a sentinel `RunResponse` matching `getActorRunOutputSchema.required` (`runId: 'mcp-passthrough'`, `actorId: baseActorName`, `status`, `storages: {}`, `summary`, `nextStep`) and forward `result.isError` from the remote tool. The remote tool's payload still flows through `content`. Stacks on #853. ## Worth your attention - **Sentinel runId** — `'mcp-passthrough'` is a deliberate non-Apify literal so logs / dashboards never mistake it for a real run id. Open to `mcp-passthrough:${mcpToolName}` for greppability per tool if preferred. - **Integration coverage** — the existing happy-path test for this code path didn't catch the regression because it never calls `client.listTools()`, so the SDK never builds the validator cache. The new test at `tests/integration/suite.ts:813` calls `listTools()` first to mirror real clients (mcpc, Claude Desktop), which is the only way to surface this class of bug at the integration layer. - **Not the architectural fix** — `call-actor` doing two unrelated things (Apify run vs. MCP tool pass-through) under one tool name is the root cause; this PR keeps that shape and just makes the response spec-compliant. Follow-up tracked in #860 (see comment). ## Follow-up - Split `call-actor` MCP pass-through into a dedicated tool, or move to code-mode as the default. Issue: #860
1 parent 586faf7 commit 9cf3a39

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

src/tools/core/call_actor_common.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,25 @@ export async function handleMcpToolCall(params: {
327327
arguments: input,
328328
});
329329

330-
return { content: result.content };
330+
// `call-actor` declares `getActorRunOutputSchema`, so MCP SDK ≥ 1.11.4 rejects any response
331+
// without `structuredContent` (unless `isError: true`) with -32600. The pass-through has no
332+
// Apify run, so synthesize a sentinel `RunResponse` matching the schema's `required` keys;
333+
// the remote tool's payload still flows through `content`. Also forward `isError` so a
334+
// failing remote tool surfaces as a failure here.
335+
const isErrorFromRemote = result.isError === true;
336+
return {
337+
content: result.content,
338+
isError: isErrorFromRemote,
339+
structuredContent: {
340+
runId: 'mcp-passthrough',
341+
actorId: baseActorName,
342+
actorName: baseActorName,
343+
status: isErrorFromRemote ? 'FAILED' : 'SUCCEEDED',
344+
storages: {},
345+
summary: `Called MCP tool '${mcpToolName}' on '${baseActorName}'.`,
346+
nextStep: 'Response content carries the remote MCP tool result; no Apify run was started.',
347+
},
348+
};
331349
} catch (error) {
332350
logHttpError(error, `Failed to call MCP tool '${mcpToolName}' on Actor '${baseActorName}'`, {
333351
actorName: baseActorName,

tests/integration/suite.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,48 @@ export function createIntegrationTestsSuite(
804804
expect(callContent.some((item) => item.text.includes(`Fetched content from ${DOCS_URL}`))).toBe(true);
805805
});
806806

807+
// Regression: `call-actor` declares an `outputSchema` (since #415), but the MCP-server pass-through
808+
// path in `handleMcpToolCall` returns `{ content }` only — no `structuredContent`. SDK ≥ 1.11.4
809+
// throws -32600 "has an output schema but did not return structured content" once it has cached
810+
// the tool validators (which happens on `listTools()` — every real client does this on connect).
811+
// The happy-path test above never calls `listTools()`, so the SDK skips validation and the bug stays
812+
// invisible at the integration layer. This test surfaces it.
813+
it('MCP server actor:tool pass-through returns structuredContent satisfying outputSchema', async () => {
814+
client = await createClientFn({ tools: ['actors'] });
815+
816+
// Populates the SDK's `_cachedToolOutputValidators` map so callTool runs schema validation.
817+
await client.listTools();
818+
819+
const callResult = await client.callTool({
820+
name: HelperTools.ACTOR_CALL,
821+
arguments: {
822+
actor: `${ACTOR_MCP_SERVER_ACTOR_NAME}:fetch-apify-docs`,
823+
input: { url: 'https://docs.apify.com' },
824+
},
825+
});
826+
827+
// structuredContent must be present and carry the keys declared `required` on
828+
// `getActorRunOutputSchema`. The pass-through path has no Apify run, so the fix is expected to
829+
// synthesize sentinel values (e.g. `runId: 'mcp-passthrough'`) rather than real run identifiers.
830+
const sc = (callResult as { structuredContent?: Record<string, unknown> }).structuredContent;
831+
expect(sc).toBeDefined();
832+
expect(sc).toHaveProperty('runId');
833+
expect(sc).toHaveProperty('actorId');
834+
expect(sc).toHaveProperty('status');
835+
expect(sc).toHaveProperty('storages');
836+
expect(sc).toHaveProperty('summary');
837+
expect(sc).toHaveProperty('nextStep');
838+
839+
// The remote MCP tool's actual result must still flow through `content` — the fix must not
840+
// lose the payload while satisfying the schema.
841+
const content = callResult.content as { text: string }[];
842+
expect(content.some((item) => item.text.includes('Fetched content from'))).toBe(true);
843+
844+
// `isError` must reflect the remote tool's status — false on the happy path. Forwarding this
845+
// closes a second drop on the same line: `handleMcpToolCall` currently discards `result.isError`.
846+
expect(callResult.isError ?? false).toBe(false);
847+
});
848+
807849
it('should search Apify documentation', async () => {
808850
client = await createClientFn({
809851
tools: ['docs'],

0 commit comments

Comments
 (0)