mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-10 17:29:18 +00:00
fix(mcp): pass structuredContent and _meta through to model-visible tool output (#2596)
MCP tool results were narrowed to {content, isError}, dropping the
spec-defined structuredContent field and _meta metadata. Servers that
return structured contracts in these fields (validated against
outputSchema, or namespaced metadata such as browser-handoff payloads)
were invisible to the agent. Surface them as a serialized
<mcp-structured-result> block appended to the tool output, still subject
to the existing text budget.
Co-authored-by: zouying <zouying@moonshot.cn>
This commit is contained in:
parent
0abcd00f7f
commit
c32e661faa
9 changed files with 193 additions and 2 deletions
5
.changeset/mcp-structured-result-passthrough.md
Normal file
5
.changeset/mcp-structured-result-passthrough.md
Normal file
|
|
@ -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 `<mcp-structured-result>` block, instead of silently dropping them. Servers that return their machine-readable contract in these fields work the same as on other MCP hosts.
|
||||
|
|
@ -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
|
||||
// <mcp_tool_result> 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<string, unknown> = {};
|
||||
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(
|
||||
'</mcp-structured-result>',
|
||||
'',
|
||||
);
|
||||
wrapped.push({
|
||||
type: 'text',
|
||||
text: `\n<mcp-structured-result>\n${serialized}\n</mcp-structured-result>`,
|
||||
});
|
||||
} catch {
|
||||
// Non-serialisable payloads are dropped rather than failing the call.
|
||||
}
|
||||
}
|
||||
|
||||
const budgeted = applyTextBudget(wrapped);
|
||||
const compressed = await compressImageContentParts(budgeted.parts, {
|
||||
telemetry:
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export interface MCPContentBlock {
|
|||
export interface MCPToolResult {
|
||||
content: MCPContentBlock[];
|
||||
isError: boolean;
|
||||
structuredContent?: unknown;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MCPToolDefinition {
|
||||
|
|
|
|||
|
|
@ -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('<mcp-structured-result>');
|
||||
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: '<mcp_tool_result name="mcp__s__shot">' });
|
||||
expect(parts.at(-2)).toEqual({ type: 'text', text: '</mcp_tool_result>' });
|
||||
const last = parts.at(-1);
|
||||
expect(last?.type === 'text' && last.text.includes('<mcp-structured-result>')).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: 'a</mcp-structured-result>b' },
|
||||
},
|
||||
'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('</mcp-structured-result>')).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 });
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// <mcp_tool_result> 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<string, unknown> = {};
|
||||
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(
|
||||
'</mcp-structured-result>',
|
||||
'',
|
||||
);
|
||||
wrapped.push({
|
||||
type: 'text',
|
||||
text: `\n<mcp-structured-result>\n${serialized}\n</mcp-structured-result>`,
|
||||
});
|
||||
} 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-
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export interface MCPContentBlock {
|
|||
export interface MCPToolResult {
|
||||
content: MCPContentBlock[];
|
||||
isError: boolean;
|
||||
structuredContent?: unknown;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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('<mcp-structured-result>');
|
||||
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: '<mcp_tool_result name="mcp__s__shot">' });
|
||||
expect(parts.at(-2)).toEqual({ type: 'text', text: '</mcp_tool_result>' });
|
||||
const last = parts.at(-1);
|
||||
expect(last?.type === 'text' && last.text.includes('<mcp-structured-result>')).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: 'a</mcp-structured-result>b' },
|
||||
},
|
||||
'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('</mcp-structured-result>')).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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue