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
73 changes: 54 additions & 19 deletions packages/agent-core-v2/src/agent/mcp/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
}
}

Expand Down Expand Up @@ -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',
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-core-v2/test/agent/mcp/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
47 changes: 45 additions & 2 deletions packages/agent-core/src/mcp/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-core/test/mcp/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading