mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-05 14:52:45 +00:00
fix(mcp): drop protocol-reserved _meta keys from model-visible output (#2600)
* fix(mcp): drop protocol-reserved _meta keys from model-visible output Follow-up to #2596. The MCP spec reserves _meta key prefixes whose labels include "modelcontextprotocol" or "mcp" for protocol use; those entries carry host/protocol plumbing rather than model-facing data, so filter them out before serializing the <mcp-structured-result> block. Unprefixed and vendor-prefixed keys still pass through — their semantics belong to the server. Also moves the v2 implementation commentary into the module header per the agent-core-v2 comment convention. * fix(mcp): reserve _meta prefixes only when a label follows mcp/modelcontextprotocol Per the spec's key-name rules a prefix is reserved when a modelcontextprotocol or mcp label is followed by at least one more label; a trailing reserved word (com.example.mcp/) is a legitimate vendor namespace and now passes through. --------- Co-authored-by: zouying <zouying@moonshot.cn>
This commit is contained in:
parent
96f77fe392
commit
74c321e4c6
4 changed files with 175 additions and 21 deletions
|
|
@ -7,19 +7,33 @@
|
|||
* (dropping unsupported shapes).
|
||||
* 2. Wrap media-only outputs in `<mcp_tool_result name="…">` tags so the
|
||||
* model can attribute binary output when several tools return media.
|
||||
* 3. Apply the 100K text/think character budget to the tool's own text.
|
||||
* 3. Serialize `structuredContent` and server `_meta` into a trailing
|
||||
* `<mcp-structured-result>` text part — appended after the media wrap so
|
||||
* a media-only result keeps its attribution tags, 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. `_meta` keys with a protocol-reserved prefix
|
||||
* (per the spec's key-name rules: a `modelcontextprotocol` or `mcp`
|
||||
* label followed by at least one more label, as in
|
||||
* `modelcontextprotocol.io/…` or `tools.mcp.com/…`, but not a vendor
|
||||
* namespace like `com.example.mcp/…`) are dropped first: they carry
|
||||
* host/protocol plumbing rather than model-facing data, while unprefixed
|
||||
* and vendor-prefixed keys pass through because their semantics belong
|
||||
* to the server. Non-serialisable payloads drop the whole block rather
|
||||
* than failing the call.
|
||||
* 4. Apply the 100K text/think character budget to the tool's own text.
|
||||
* This runs BEFORE captions exist, so a chatty tool (page text + a
|
||||
* screenshot) can never evict or slice the compression caption — that
|
||||
* would silently reintroduce the very degradation the caption reports.
|
||||
* 4. Compress oversized inline images, announcing each compression with a
|
||||
* 5. Compress oversized inline images, announcing each compression with a
|
||||
* caption (original vs. sent size, readback path to the persisted
|
||||
* original) so downsampling is never silent. The captions ride the
|
||||
* result's `note` side channel — projected to the model at fold time, but
|
||||
* kept out of `output` so UIs never render them.
|
||||
* 5. Apply the per-part 10 MB binary cap: oversized binary parts
|
||||
* 6. Apply the per-part 10 MB binary cap: oversized binary parts
|
||||
* (image/audio/video URLs) collapse to a notice, so a single
|
||||
* screenshot cannot evict every text part.
|
||||
* 6. Collapse a single-text-part result to a plain string output; otherwise
|
||||
* 7. Collapse a single-text-part result to a plain string output; otherwise
|
||||
* emit the `ContentPart[]` as-is.
|
||||
*
|
||||
* `mcpResultToExecutableOutput` is the single entry point; the per-step
|
||||
|
|
@ -146,32 +160,23 @@ 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;
|
||||
const meta = stripReservedMetaKeys(result._meta);
|
||||
if (meta !== undefined) {
|
||||
structuredExtras['_meta'] = meta;
|
||||
}
|
||||
}
|
||||
if (Object.keys(structuredExtras).length > 0) {
|
||||
try {
|
||||
const serialized = JSON.stringify(structuredExtras).replaceAll(
|
||||
'</mcp-structured-result>',
|
||||
'',
|
||||
);
|
||||
const serialized = serializeStructuredExtras(structuredExtras);
|
||||
if (serialized !== undefined) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +207,36 @@ export async function mcpResultToExecutableOutput(
|
|||
};
|
||||
}
|
||||
|
||||
function serializeStructuredExtras(extras: Record<string, unknown>): string | undefined {
|
||||
try {
|
||||
return JSON.stringify(extras).replaceAll('</mcp-structured-result>', '');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stripReservedMetaKeys(
|
||||
meta: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (!isReservedMetaKey(key)) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function isReservedMetaKey(key: string): boolean {
|
||||
const slash = key.indexOf('/');
|
||||
if (slash <= 0) return false;
|
||||
const labels = key.slice(0, slash).split('.');
|
||||
return labels.some(
|
||||
(label, i) =>
|
||||
(label === 'modelcontextprotocol' || label === 'mcp') && i < labels.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string): ContentPart[] {
|
||||
const hasMedia = parts.some(
|
||||
(p) => p.type === 'image_url' || p.type === 'audio_url' || p.type === 'video_url',
|
||||
|
|
|
|||
|
|
@ -317,6 +317,44 @@ describe('mcpResultToExecutableOutput', () => {
|
|||
expect(joined.split('</mcp-structured-result>')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('drops protocol-reserved _meta keys and keeps vendor namespaces', async () => {
|
||||
const out = await mcpResultToExecutableOutput(
|
||||
{
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
_meta: {
|
||||
'modelcontextprotocol.io/progress': 1,
|
||||
'tools.mcp.com/trace': 'x',
|
||||
'example.com/custom': 2,
|
||||
// Reserved only when another label FOLLOWS mcp/modelcontextprotocol:
|
||||
// a trailing reserved word is a legitimate vendor namespace.
|
||||
'com.example.mcp/trace': 4,
|
||||
vendorKey: 3,
|
||||
},
|
||||
},
|
||||
'mcp__s__t',
|
||||
);
|
||||
const parts = out.output as ContentPart[];
|
||||
const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
||||
expect(joined).not.toContain('modelcontextprotocol.io/progress');
|
||||
expect(joined).not.toContain('tools.mcp.com/trace');
|
||||
expect(joined).toContain('"example.com/custom":2');
|
||||
expect(joined).toContain('"com.example.mcp/trace":4');
|
||||
expect(joined).toContain('"vendorKey":3');
|
||||
});
|
||||
|
||||
test('omits the structured block when every _meta key is protocol-reserved', async () => {
|
||||
const out = await mcpResultToExecutableOutput(
|
||||
{
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
_meta: { 'mcp.dev/internal': true },
|
||||
},
|
||||
'mcp__s__t',
|
||||
);
|
||||
expect(out).toEqual({ output: 'ok', isError: false });
|
||||
});
|
||||
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -193,13 +193,17 @@ export async function mcpResultToExecutableOutput(
|
|||
// <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.
|
||||
// block. Protocol-reserved _meta keys are dropped first: those carry
|
||||
// host/protocol plumbing, not model-facing data.
|
||||
const structuredExtras: Record<string, unknown> = {};
|
||||
if (result.structuredContent !== undefined) {
|
||||
structuredExtras['structuredContent'] = result.structuredContent;
|
||||
}
|
||||
if (result._meta !== undefined) {
|
||||
structuredExtras['_meta'] = result._meta;
|
||||
const meta = stripReservedMetaKeys(result._meta);
|
||||
if (meta !== undefined) {
|
||||
structuredExtras['_meta'] = meta;
|
||||
}
|
||||
}
|
||||
if (Object.keys(structuredExtras).length > 0) {
|
||||
try {
|
||||
|
|
@ -257,6 +261,45 @@ export async function mcpResultToExecutableOutput(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop protocol-reserved `_meta` keys before the payload reaches the model.
|
||||
*
|
||||
* Per the MCP spec's `_meta` key-name rules, a key may carry a dot-separated
|
||||
* label prefix terminated by `/`; a prefix is reserved for protocol use when
|
||||
* a `modelcontextprotocol` or `mcp` label is followed by at least one more
|
||||
* label (e.g. `modelcontextprotocol.io/…`, `tools.mcp.com/…` — but not a
|
||||
* vendor namespace like `com.example.mcp/…`). Reserved entries carry
|
||||
* host/protocol plumbing — progress and task wiring, UI component payloads —
|
||||
* that servers do not address to the model, so forwarding them would leak
|
||||
* side-channel data into the conversation. Unprefixed and vendor-prefixed
|
||||
* keys pass through untouched: their semantics belong to the server, and the
|
||||
* host cannot know which of them the model is meant to see.
|
||||
*
|
||||
* Returns `undefined` when nothing survives, so callers can omit the `_meta`
|
||||
* section entirely.
|
||||
*/
|
||||
function stripReservedMetaKeys(
|
||||
meta: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (!isReservedMetaKey(key)) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function isReservedMetaKey(key: string): boolean {
|
||||
const slash = key.indexOf('/');
|
||||
if (slash <= 0) return false;
|
||||
const labels = key.slice(0, slash).split('.');
|
||||
return labels.some(
|
||||
(label, i) =>
|
||||
(label === 'modelcontextprotocol' || label === 'mcp') && i < labels.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If `parts` contains media but no non-empty text, surround it with
|
||||
* `<mcp_tool_result name="…">` text tags so the model can attribute the
|
||||
|
|
|
|||
|
|
@ -316,6 +316,44 @@ describe('mcpResultToExecutableOutput', () => {
|
|||
expect(joined.split('</mcp-structured-result>')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('drops protocol-reserved _meta keys and keeps vendor namespaces', async () => {
|
||||
const out = await mcpResultToExecutableOutput(
|
||||
{
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
_meta: {
|
||||
'modelcontextprotocol.io/progress': 1,
|
||||
'tools.mcp.com/trace': 'x',
|
||||
'example.com/custom': 2,
|
||||
// Reserved only when another label FOLLOWS mcp/modelcontextprotocol:
|
||||
// a trailing reserved word is a legitimate vendor namespace.
|
||||
'com.example.mcp/trace': 4,
|
||||
vendorKey: 3,
|
||||
},
|
||||
},
|
||||
'mcp__s__t',
|
||||
);
|
||||
const parts = out.output as ContentPart[];
|
||||
const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
||||
expect(joined).not.toContain('modelcontextprotocol.io/progress');
|
||||
expect(joined).not.toContain('tools.mcp.com/trace');
|
||||
expect(joined).toContain('"example.com/custom":2');
|
||||
expect(joined).toContain('"com.example.mcp/trace":4');
|
||||
expect(joined).toContain('"vendorKey":3');
|
||||
});
|
||||
|
||||
test('omits the structured block when every _meta key is protocol-reserved', async () => {
|
||||
const out = await mcpResultToExecutableOutput(
|
||||
{
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
_meta: { 'mcp.dev/internal': true },
|
||||
},
|
||||
'mcp__s__t',
|
||||
);
|
||||
expect(out).toEqual({ output: 'ok', isError: false });
|
||||
});
|
||||
|
||||
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