From 74c321e4c63c19c44be21362f5178b3e4130abb0 Mon Sep 17 00:00:00 2001 From: zy Date: Tue, 4 Aug 2026 20:03:10 +0800 Subject: [PATCH] fix(mcp): drop protocol-reserved _meta keys from model-visible output (#2600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 --- .../agent-core-v2/src/agent/mcp/output.ts | 73 ++++++++++++++----- .../test/agent/mcp/output.test.ts | 38 ++++++++++ packages/agent-core/src/mcp/output.ts | 47 +++++++++++- packages/agent-core/test/mcp/output.test.ts | 38 ++++++++++ 4 files changed, 175 insertions(+), 21 deletions(-) diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 92ea4b19f..d2af05e44 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -7,19 +7,33 @@ * (dropping unsupported shapes). * 2. Wrap media-only outputs in `` 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 + * `` 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 - // 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; + const meta = stripReservedMetaKeys(result._meta); + if (meta !== undefined) { + structuredExtras['_meta'] = meta; + } } if (Object.keys(structuredExtras).length > 0) { - try { - const serialized = JSON.stringify(structuredExtras).replaceAll( - '', - '', - ); + const serialized = serializeStructuredExtras(structuredExtras); + if (serialized !== undefined) { wrapped.push({ type: 'text', text: `\n\n${serialized}\n`, }); - } catch { - // Non-serialisable payloads are dropped rather than failing the call. } } @@ -202,6 +207,36 @@ export async function mcpResultToExecutableOutput( }; } +function serializeStructuredExtras(extras: Record): string | undefined { + try { + return JSON.stringify(extras).replaceAll('', ''); + } catch { + return undefined; + } +} + +function stripReservedMetaKeys( + meta: Record, +): Record | undefined { + const out: Record = {}; + 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', 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 aa136119d..5b7963d4f 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -317,6 +317,44 @@ describe('mcpResultToExecutableOutput', () => { expect(joined.split('')).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 }); diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index d3f85c9a5..00e64f407 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -193,13 +193,17 @@ export async function mcpResultToExecutableOutput( // 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 = {}; 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, +): Record | undefined { + const out: Record = {}; + 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 * `` text tags so the model can attribute the diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index b9aff8620..9fca2499f 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -316,6 +316,44 @@ describe('mcpResultToExecutableOutput', () => { expect(joined.split('')).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