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