Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-structured-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Preserve distinct structured data in MCP tool results.
2 changes: 2 additions & 0 deletions docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open protocol that lets models safely call tools exposed by external processes or services: reading GitHub issues, querying databases, or operating the local file system. Kimi Code CLI acts as an MCP client to connect these external tools and exposes them to the Agent alongside built-in tools (`Read`, `Bash`, `Grep`, etc.) with no behavioral difference.

MCP tool results can include text (`content`) and structured data (`structuredContent`). Kimi Code CLI makes both available to the agent and omits the structured copy only when it can confirm that a text block already contains the same complete JSON value. Text summaries and media do not replace structured records.

## Connection Methods

Kimi Code CLI supports three MCP server connection methods:
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

[Model Context Protocol(MCP)](https://modelcontextprotocol.io/) 是一个开放协议,让模型可以安全地调用外部进程或服务暴露的工具:读取 GitHub issues、查询数据库、操作本地文件系统。Kimi Code CLI 作为 MCP client 接入这些外部工具,把它们与内置工具一起暴露给 Agent 使用,行为上没有差异。

MCP 工具结果可以包含文本(`content`)和结构化数据(`structuredContent`)。Kimi Code CLI 会将两者提供给 Agent,只有能够确认某个文本块已包含同一份完整 JSON 值时,才省略重复的结构化内容。文本摘要和媒体不会替代结构化记录。

## 接入方式

Kimi Code CLI 支持三种 MCP server 接入方式:
Expand Down
26 changes: 21 additions & 5 deletions packages/agent-core-v2/src/agent/mcp/output.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isDeepStrictEqual } from 'node:util';

import type { ContentPart } from '#human/llm/message';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import type { ExecutableToolResult } from '#/tool/toolContract';
Expand Down Expand Up @@ -119,11 +121,16 @@ export async function mcpResultToExecutableOutput(
}

const wrapped = wrapMediaOnly(converted, qualifiedToolName);
const hasUsableContent = converted.some((part) =>
part.type === 'text' ? part.text.trim().length > 0 : true,
);
const hasStructuredCopy = result.structuredContent !== undefined && converted.some((part) => {
if (part.type !== 'text') return false;
try {
return isDeepStrictEqual(parseComparableJson(part.text), result.structuredContent);
} catch {
return false;
}
});
const structuredExtras: Record<string, unknown> = {};
if (result.structuredContent !== undefined && !hasUsableContent) {
if (result.structuredContent !== undefined && !hasStructuredCopy) {
structuredExtras['structuredContent'] = result.structuredContent;
}
if (result._meta !== undefined) {
Expand Down Expand Up @@ -167,9 +174,18 @@ export async function mcpResultToExecutableOutput(
return result.isError ? { ...base, isError: true } : base;
}

function parseComparableJson(text: string): unknown {
return JSON.parse(text, (_key: string, value: unknown, context?: { source?: string }) => {
if (typeof value === 'number' && context?.source !== JSON.stringify(value)) {
throw new Error('JSON number cannot be compared without normalization');
}
return value;
});
}

function serializeStructuredExtras(extras: Record<string, unknown>): string | undefined {
try {
return JSON.stringify(extras).replaceAll('</mcp-result-extras>', '');
return JSON.stringify(extras).replaceAll('<', '\\u003c');
} catch {
return undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/human/test/llm/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ describe('openai requester thinking', () => {
{
signal: new AbortController().signal,
onEvent: (event) => {
if (event.type === 'llm.delta') explicitParts.push(event.part);
if (event.type === 'llm.streaming.part') explicitParts.push(event.part);
},
},
);
Expand Down
134 changes: 117 additions & 17 deletions packages/agent-core-v2/test/agent/mcp/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is
return typeof (value as Promise<ToolExecution>).then === 'function';
}

function parseResultExtras(output: string | ContentPart[]): Record<string, unknown> {
const text = typeof output === 'string'
? output
: output.map((part) => part.type === 'text' ? part.text : '').join('\n');
const json = /<mcp-result-extras>\n([\s\S]*?)\n<\/mcp-result-extras>/.exec(text)?.[1];
if (json === undefined) throw new Error('Expected model-visible MCP result extras');
return JSON.parse(json) as Record<string, unknown>;
}

function assertValidMcpBlock<T extends MCPContentBlock>(block: T): T {
const parsed = ContentBlockSchema.safeParse(block);
if (!parsed.success) {
Expand Down Expand Up @@ -351,9 +360,78 @@ describe('mcpResultToExecutableOutput', () => {
expect(out.output).toBe('{\n "total": 1,\n "rows": [ { "id": 1 } ]\n}');
});

test('omits structuredContent when content is a faithful rendering of similar size', async () => {
test('preserves both values when parsing the text would round a number', async () => {
const text = '{"id":9007199254740993}';
const out = await mcpResultToExecutableOutput({
content: [{ type: 'text', text }],
isError: false,
structuredContent: { id: 9007199254740992 },
}, 'mcp__s__t');

expect(out.output).toContainEqual({ type: 'text', text });
expect(parseResultExtras(out.output)['structuredContent']).toEqual({ id: 9007199254740992 });
});

test.each([
{ difference: 'value', text: '{"count":1}', structuredContent: { count: 2 } },
{ difference: 'type', text: '{"id":"1"}', structuredContent: { id: 1 } },
{ difference: 'array order', text: '{"ids":[2,1]}', structuredContent: { ids: [1, 2] } },
{ difference: 'extra field', text: '{"id":1}', structuredContent: { id: 1, name: 'Example' } },
{ difference: 'null', text: '{"value":"none"}', structuredContent: { value: null } },
{ difference: 'non-JSON wrapper', text: '```json\n{"id":1}\n```', structuredContent: { id: 1 } },
])('keeps text and structured data with a $difference difference', async ({ text, structuredContent }) => {
const out = await mcpResultToExecutableOutput({
content: [{ type: 'text', text }],
isError: false,
structuredContent,
}, 'mcp__s__t');

expect(out.output).toContainEqual({ type: 'text', text });
expect(parseResultExtras(out.output)['structuredContent']).toEqual(structuredContent);
});

test('keeps explanatory blocks when another text block contains the complete JSON', async () => {
const content = [
{ type: 'text', text: 'Found 1 row.' },
{ type: 'text', text: '{"rows":[1]}' },
{ type: 'text', text: 'More rows are available.' },
];
const out = await mcpResultToExecutableOutput({
content,
isError: false,
structuredContent: { rows: [1] },
}, 'mcp__s__t');

expect(out.output).toEqual(content);
});

test('keeps structured error details and the tool error status', async () => {
const out = await mcpResultToExecutableOutput({
content: [{ type: 'text', text: 'Request failed.' }],
isError: true,
structuredContent: { code: 'EXAMPLE_ERROR', retryable: false },
}, 'mcp__s__t');

expect(out.isError).toBe(true);
expect(parseResultExtras(out.output)['structuredContent']).toEqual({
code: 'EXAMPLE_ERROR', retryable: false,
});
});

test('does not let a dropped-content notice hide structured data', async () => {
const out = await mcpResultToExecutableOutput({
content: [{ type: 'example-unsupported' }],
isError: false,
structuredContent: { id: 'EXAMPLE_RECORD' },
}, 'mcp__s__t');

expect(parseResultExtras(out.output)['structuredContent']).toEqual({ id: 'EXAMPLE_RECORD' });
expect(JSON.stringify(out.output)).toContain('MCP content dropped');
});

test('preserves structured values alongside a human-readable rendering', async () => {
const text =
'Project: Central Macaw [d594e625]\n' +
'Project: Example Project [example-project]\n' +
'Description: none\n' +
'Timeline: 1920x1080 @ 30fps | durationInFrames=0\n' +
'Assets: total=0';
Expand All @@ -362,17 +440,22 @@ describe('mcpResultToExecutableOutput', () => {
content: [{ type: 'text', text }],
isError: false,
structuredContent: {
project: { id: 'd594e625', name: 'Central Macaw', description: null },
project: { id: 'example-project', name: 'Example Project', description: null },
timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 },
assets: { total: 0 },
},
},
'mcp__s__t',
);
expect(out.output).toBe(text);
expect(out.output).toContainEqual({ type: 'text', text });
expect(parseResultExtras(out.output)['structuredContent']).toEqual({
project: { id: 'example-project', name: 'Example Project', description: null },
timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 },
assets: { total: 0 },
});
});

test('suppresses structuredContent whenever content carries usable text', async () => {
test('preserves structured records alongside a prose summary', async () => {
const out = await mcpResultToExecutableOutput(
{
content: [{ type: 'text', text: 'list_projects returned 6 item(s).' }],
Expand All @@ -390,7 +473,17 @@ describe('mcpResultToExecutableOutput', () => {
},
'mcp__s__t',
);
expect(out.output).toBe('list_projects returned 6 item(s).');
expect(out.output).toContainEqual({ type: 'text', text: 'list_projects returned 6 item(s).' });
expect(parseResultExtras(out.output)['structuredContent']).toEqual({
projects: [
{ id: 'p1', name: 'Alpha' },
{ id: 'p2', name: 'Beta' },
{ id: 'p3', name: 'Gamma' },
{ id: 'p4', name: 'Delta' },
{ id: 'p5', name: 'Epsilon' },
{ id: 'p6', name: 'Zeta' },
],
});
});

test('falls back to structuredContent when content carries no usable text', async () => {
Expand All @@ -408,7 +501,7 @@ describe('mcpResultToExecutableOutput', () => {
expect(joined).toContain('"structuredContent":{"foo":1}');
});

test('keeps the mcp_tool_result wrap for media-only results and suppresses structuredContent', async () => {
test('keeps media and its structured data together', async () => {
const out = await mcpResultToExecutableOutput(
{
content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }],
Expand All @@ -419,23 +512,27 @@ describe('mcpResultToExecutableOutput', () => {
);
const parts = out.output as ContentPart[];
expect(parts[0]).toEqual({ type: 'text', text: '<mcp_tool_result name="mcp__s__shot">' });
expect(parts.at(-1)).toEqual({ type: 'text', text: '</mcp_tool_result>' });
const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
expect(joined).not.toContain('<mcp-result-extras>');
expect(parts).toContainEqual({ type: 'text', text: '</mcp_tool_result>' });
expect(parts.some((part) => part.type === 'image_url')).toBe(true);
expect(parseResultExtras(out.output)['structuredContent']).toEqual({ foo: 1 });
});

test('strips literal closing tags inside the structured payload', async () => {
test('escapes literal closing tags without changing structured values', async () => {
const out = await mcpResultToExecutableOutput(
{
content: [{ type: 'text', text: 'ok' }],
isError: false,
structuredContent: { text: 'a</mcp-result-extras>b' },
_meta: { evil: 'a</mcp-result-extras>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"');
expect(parseResultExtras(out.output)).toEqual({
structuredContent: { text: 'a</mcp-result-extras>b' },
_meta: { evil: 'a</mcp-result-extras>b' },
});
expect(joined.split('</mcp-result-extras>')).toHaveLength(2);
});

Expand Down Expand Up @@ -899,18 +996,21 @@ describe('mcpResultToExecutableOutput over a real stdio server', () => {
expect(text).toContain('"structuredContent":{"rows":[{"id":1}],"total":1}');
}, 15000);

test('a prose summary suppresses the structured payload', async () => {
test('a prose summary and its structured records both survive stdio transport', async () => {
const out = await callFixtureTool('prose_plus_structured');
const text = joinedText(out.output);
expect(text).toContain('Found 1 row.');
expect(text).not.toContain('<mcp-result-extras>');
expect(parseResultExtras(out.output)['structuredContent']).toEqual({ rows: [{ id: 1 }], total: 1 });
}, 15000);

test('a faithful rendering of similar size suppresses the structured copy', async () => {
test('a human-readable rendering retains its structured values over stdio', async () => {
const out = await callFixtureTool('faithful_rendering');
const text = joinedText(out.output);
expect(text).toContain('Project: Central Macaw');
expect(text).not.toContain('<mcp-result-extras>');
expect(text).toContain('Project: Example Project');
expect(parseResultExtras(out.output)['structuredContent']).toMatchObject({
project: { description: null },
timeline: { durationInFrames: 0 },
});
}, 15000);

test('vendor _meta keys pass through alongside content text', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
type ToolUpdate,
} from '#/tool/toolContract';
import { ToolOutputAccumulator } from '#/tool/output-accumulator';
import { createMcpTool } from '#/agent/mcp/tools/mcp';
import type { MCPClient } from '#/mcpCore/types';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type {
BeforeToolExecuteEvent,
Expand Down Expand Up @@ -1127,6 +1129,63 @@ describe('truncation pipeline', () => {
expect(readFileSync(outputPath, 'utf8')).toBe(fullOutput);
});

it('recovers MCP structured records through spill and Read without repeating the MCP call', async () => {
const structuredContent = {
rows: Array.from({ length: 1200 }, (_, index) => ({
id: index + 1,
detail: 'x'.repeat(100),
})),
literal: 'a</mcp-result-extras>b',
};
const client = {
async listTools() { return []; },
callTool: vi.fn(async () => ({
content: [{ type: 'text', text: 'Found 1200 rows.' }],
isError: false,
structuredContent,
})),
async ping() {},
} satisfies MCPClient;
registry.register(createMcpTool(
'mcp__example__rows',
{ name: 'rows', description: 'Example records', parameters: {} },
client,
), { source: 'mcp' });

const [result] = await execute([toolCall('call_rows', 'mcp__example__rows', {})]);

expect(result?.isError).not.toBe(true);
expect(result?.truncated).toBe(true);
if (result === undefined) throw new Error('expected MCP result');
const visible = renderToolResultForModel(result)
.map((part) => part.type === 'text' ? part.text : '').join('\n');
expect(visible.length).toBeLessThan(50_000);
const path = renderedOutputPath(visible);
let args: ReadInput | undefined = { path, max_chars: 16_000 };
let recovered = '';
let pages = 0;
while (args !== undefined && pages < 30) {
const [page] = await execute([toolCall(`read_mcp_${String(pages++)}`, 'Read', args)]);
expect(page?.isError).not.toBe(true);
if (typeof page?.output !== 'string') throw new Error('expected Read text');
const pageText = renderToolResultForModel(page)
.map((part) => part.type === 'text' ? part.text : '').join('\n');
expect(pageText.length).toBeLessThanOrEqual(16_000);
if (recovered.length > 0 && (args.column_offset ?? 0) === 0) recovered += '\n';
recovered += page.output.replaceAll(/^\d+\t/gm, '');
const next = /Next Read: (\{[^\n]*\})/.exec(page.note ?? '')?.[1];
args = next === undefined ? undefined : ReadInputSchema.parse(JSON.parse(next));
}

expect(args).toBeUndefined();
expect(pages).toBeGreaterThan(2);
expect(recovered).toContain('Found 1200 rows.');
const json = /<mcp-result-extras>\n([\s\S]*?)\n<\/mcp-result-extras>/.exec(recovered)?.[1];
if (json === undefined) throw new Error('expected recovered MCP result extras');
expect(JSON.parse(json)).toEqual({ structuredContent });
expect(client.callTool).toHaveBeenCalledTimes(1);
});

it('keeps the builder completion message after spilling an error result', async () => {
const fullOutput = `${'x'.repeat(50_001)}tail`;
const tool = new TestTool('failing-noisy', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ server.registerTool(
content: [
{
type: 'text',
text: 'Project: Central Macaw [d594e625]\nDescription: none\nTimeline: 1920x1080 @ 30fps | durationInFrames=0\nAssets: total=0',
text: 'Project: Example Project [example-project]\nDescription: none\nTimeline: 1920x1080 @ 30fps | durationInFrames=0\nAssets: total=0',
},
],
structuredContent: {
project: { id: 'd594e625', name: 'Central Macaw', description: null },
project: { id: 'example-project', name: 'Example Project', description: null },
timeline: { width: 1920, height: 1080, fps: 30, durationInFrames: 0 },
assets: { total: 0 },
},
Expand Down
Loading