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:
zy 2026-08-04 20:03:10 +08:00 committed by GitHub
parent 96f77fe392
commit 74c321e4c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 175 additions and 21 deletions

View file

@ -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',

View file

@ -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 });

View file

@ -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

View file

@ -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