diff --git a/.changeset/mcp-structured-result-passthrough.md b/.changeset/mcp-structured-result-passthrough.md new file mode 100644 index 0000000000..d63740fe8b --- /dev/null +++ b/.changeset/mcp-structured-result-passthrough.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +MCP tool results now surface the spec-defined `structuredContent` field and `_meta` server metadata to the model as a serialized `` block, instead of silently dropping them. Servers that return their machine-readable contract in these fields work the same as on other MCP hosts. diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 7e65918a94..92ea4b19ff 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -146,6 +146,35 @@ export async function mcpResultToExecutableOutput( } const wrapped = wrapMediaOnly(converted, qualifiedToolName); + // Structured payloads (structuredContent per MCP spec, plus server metadata + // in _meta) carry machine-readable contracts such as browser-handoff URLs. + // Appended AFTER the media wrap so a media-only result keeps its + // attribution, and BEFORE the text budget so oversized + // payloads stay bounded. Literal closing tags inside the serialized + // payload are stripped so server data cannot fake an early end of the + // block. + const structuredExtras: Record = {}; + if (result.structuredContent !== undefined) { + structuredExtras['structuredContent'] = result.structuredContent; + } + if (result._meta !== undefined) { + structuredExtras['_meta'] = result._meta; + } + if (Object.keys(structuredExtras).length > 0) { + try { + const serialized = JSON.stringify(structuredExtras).replaceAll( + '', + '', + ); + wrapped.push({ + type: 'text', + text: `\n\n${serialized}\n`, + }); + } catch { + // Non-serialisable payloads are dropped rather than failing the call. + } + } + const budgeted = applyTextBudget(wrapped); const compressed = await compressImageContentParts(budgeted.parts, { telemetry: diff --git a/packages/agent-core-v2/src/mcpCore/client-shared.ts b/packages/agent-core-v2/src/mcpCore/client-shared.ts index 69e8341ec4..6d2ead018e 100644 --- a/packages/agent-core-v2/src/mcpCore/client-shared.ts +++ b/packages/agent-core-v2/src/mcpCore/client-shared.ts @@ -79,11 +79,21 @@ export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition { export function toMcpToolResult(result: unknown): MCPToolResult { if (typeof result === 'object' && result !== null && 'content' in result) { - const typed = result as { content: unknown; isError?: unknown }; + const typed = result as { + content: unknown; + isError?: unknown; + structuredContent?: unknown; + _meta?: unknown; + }; if (Array.isArray(typed.content)) { return { content: typed.content as MCPToolResult['content'], isError: typed.isError === true, + structuredContent: typed.structuredContent, + _meta: + typeof typed._meta === 'object' && typed._meta !== null + ? (typed._meta as Record) + : undefined, }; } } diff --git a/packages/agent-core-v2/src/mcpCore/types.ts b/packages/agent-core-v2/src/mcpCore/types.ts index 09e9d8c712..522cbc396b 100644 --- a/packages/agent-core-v2/src/mcpCore/types.ts +++ b/packages/agent-core-v2/src/mcpCore/types.ts @@ -34,6 +34,8 @@ export interface MCPContentBlock { export interface MCPToolResult { content: MCPContentBlock[]; isError: boolean; + structuredContent?: unknown; + _meta?: Record; } export interface MCPToolDefinition { diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index c2f0443dab..aa136119d7 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -265,6 +265,58 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: 'oops', isError: true }); }); + test('surfaces structuredContent and _meta as a serialized mcp-structured-result block', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: 'ok' }], + isError: false, + structuredContent: { foo: 1 }, + _meta: { bar: 2 }, + }, + 'mcp__s__t', + ); + const parts = out.output as ContentPart[]; + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).toContain(''); + expect(joined).toContain('"structuredContent":{"foo":1}'); + expect(joined).toContain('"_meta":{"bar":2}'); + expect(out.isError).toBe(false); + }); + + test('keeps the mcp_tool_result wrap when a media-only result carries structuredContent', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }], + isError: false, + structuredContent: { foo: 1 }, + }, + 'mcp__s__shot', + ); + const parts = out.output as ContentPart[]; + // The structured block sits OUTSIDE the media wrap, after the closing + // tag, so the image keeps its tool attribution. + expect(parts[0]).toEqual({ type: 'text', text: '' }); + expect(parts.at(-2)).toEqual({ type: 'text', text: '' }); + const last = parts.at(-1); + expect(last?.type === 'text' && last.text.includes('')).toBe(true); + }); + + test('strips literal closing tags inside the structured payload', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: 'ok' }], + isError: false, + _meta: { evil: 'ab' }, + }, + 'mcp__s__t', + ); + const parts = out.output as ContentPart[]; + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).toContain('"evil":"ab"'); + // Exactly one closing tag survives: the wrapper's own. + expect(joined.split('')).toHaveLength(2); + }); + test('returns an empty output array when the content array is empty', async () => { const out = await mcpResultToExecutableOutput(result([]), 'mcp__s__t'); expect(out).toEqual({ output: [], isError: false }); diff --git a/packages/agent-core/src/mcp/client-shared.ts b/packages/agent-core/src/mcp/client-shared.ts index 3b9fec4429..18ee914e5d 100644 --- a/packages/agent-core/src/mcp/client-shared.ts +++ b/packages/agent-core/src/mcp/client-shared.ts @@ -67,11 +67,21 @@ export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition { */ export function toMcpToolResult(result: unknown): MCPToolResult { if (typeof result === 'object' && result !== null && 'content' in result) { - const typed = result as { content: unknown; isError?: unknown }; + const typed = result as { + content: unknown; + isError?: unknown; + structuredContent?: unknown; + _meta?: unknown; + }; if (Array.isArray(typed.content)) { return { content: typed.content as MCPToolResult['content'], isError: typed.isError === true, + structuredContent: typed.structuredContent, + _meta: + typeof typed._meta === 'object' && typed._meta !== null + ? (typed._meta as Record) + : undefined, }; } } diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 08fe82e9a1..d3f85c9a59 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -187,6 +187,35 @@ export async function mcpResultToExecutableOutput( } const wrapped = wrapMediaOnly(converted, qualifiedToolName); + // Structured payloads (structuredContent per MCP spec, plus server metadata + // in _meta) carry machine-readable contracts such as browser-handoff URLs. + // Appended AFTER the media wrap so a media-only result keeps its + // attribution, and BEFORE the text budget so oversized + // payloads stay bounded. Literal closing tags inside the serialized + // payload are stripped so server data cannot fake an early end of the + // block. + const structuredExtras: Record = {}; + if (result.structuredContent !== undefined) { + structuredExtras['structuredContent'] = result.structuredContent; + } + if (result._meta !== undefined) { + structuredExtras['_meta'] = result._meta; + } + if (Object.keys(structuredExtras).length > 0) { + try { + const serialized = JSON.stringify(structuredExtras).replaceAll( + '', + '', + ); + wrapped.push({ + type: 'text', + text: `\n\n${serialized}\n`, + }); + } catch { + // Non-serialisable payloads are dropped rather than failing the call. + } + } + // Text budget FIRST, on the tool's own text only: captions produced by the // compression step below ride the `note` side channel and never compete // with a chatty tool's text for the budget — an evicted or mid-string- diff --git a/packages/agent-core/src/mcp/types.ts b/packages/agent-core/src/mcp/types.ts index dee8cf4eb6..aedb555406 100644 --- a/packages/agent-core/src/mcp/types.ts +++ b/packages/agent-core/src/mcp/types.ts @@ -51,6 +51,8 @@ export interface MCPContentBlock { export interface MCPToolResult { content: MCPContentBlock[]; isError: boolean; + structuredContent?: unknown; + _meta?: Record; } /** diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index 19023b0b54..b9aff86201 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -264,6 +264,58 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: 'oops', isError: true }); }); + test('surfaces structuredContent and _meta as a serialized mcp-structured-result block', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: 'ok' }], + isError: false, + structuredContent: { foo: 1 }, + _meta: { bar: 2 }, + }, + 'mcp__s__t', + ); + const parts = out.output as ContentPart[]; + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).toContain(''); + expect(joined).toContain('"structuredContent":{"foo":1}'); + expect(joined).toContain('"_meta":{"bar":2}'); + expect(out.isError).toBe(false); + }); + + test('keeps the mcp_tool_result wrap when a media-only result carries structuredContent', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }], + isError: false, + structuredContent: { foo: 1 }, + }, + 'mcp__s__shot', + ); + const parts = out.output as ContentPart[]; + // The structured block sits OUTSIDE the media wrap, after the closing + // tag, so the image keeps its tool attribution. + expect(parts[0]).toEqual({ type: 'text', text: '' }); + expect(parts.at(-2)).toEqual({ type: 'text', text: '' }); + const last = parts.at(-1); + expect(last?.type === 'text' && last.text.includes('')).toBe(true); + }); + + test('strips literal closing tags inside the structured payload', async () => { + const out = await mcpResultToExecutableOutput( + { + content: [{ type: 'text', text: 'ok' }], + isError: false, + _meta: { evil: 'ab' }, + }, + 'mcp__s__t', + ); + const parts = out.output as ContentPart[]; + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).toContain('"evil":"ab"'); + // Exactly one closing tag survives: the wrapper's own. + expect(joined.split('')).toHaveLength(2); + }); + test('returns an empty string when the content array is empty', async () => { const out = await mcpResultToExecutableOutput(result([]), 'mcp__s__t'); // No parts survive; collapseSingleText has nothing to collapse so the