Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a6b22ca
fix(core): bound oversized images returned by MCP tools
yiliang114 Sep 2, 2026
c9937ac
Merge branch 'main' into fix/mcp-image-context-budget
yiliang114 Sep 3, 2026
39789a8
Merge branch 'main' into fix/mcp-image-context-budget
yiliang114 Sep 3, 2026
b9b6e5e
Merge branch 'main' into fix/mcp-image-context-budget
wenshao Sep 3, 2026
55c8b4e
Merge branch 'main' into fix/mcp-image-context-budget
yiliang114 Sep 3, 2026
c8e0c82
fix(core): load the image renderer before file-level checks
yiliang114 Sep 12, 2026
c94b650
Merge remote-tracking branch 'origin/main' into fix/mcp-image-context…
yiliang114 Sep 15, 2026
2d4f73e
Merge remote-tracking branch 'origin/main' into fix/mcp-image-context…
yiliang114 Sep 15, 2026
444b6d3
Merge remote-tracking branch 'origin/main' into fix/mcp-image-context…
yiliang114 Sep 15, 2026
2d6a95c
Merge remote-tracking branch 'origin/main' into fix/mcp-image-context…
yiliang114 Sep 15, 2026
6b68d25
Merge branch 'main' into fix/mcp-image-context-budget
yiliang114 Sep 18, 2026
c2b49c7
fix(core): serialize MCP image bounding
yiliang114 Sep 18, 2026
c96b968
fix(core): guard oversized MCP image payloads
yiliang114 Sep 19, 2026
483d40b
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Sep 19, 2026
7ffeb8e
Merge branch 'main' into fix/mcp-image-context-budget
yiliang114 Sep 19, 2026
831e50f
fix(core): address MCP image bounding review findings
yiliang114 Sep 19, 2026
39239af
fix(core): bound untyped resource images and all inline media from MC…
yiliang114 Sep 19, 2026
2fcd6b6
fix(core): keep non-image MCP media out of the inline-limit clamp
yiliang114 Sep 19, 2026
3e83986
test(core): pin the image-only inline-media boundary
yiliang114 Sep 20, 2026
e9dc124
Merge origin/main into fix/mcp-image-context-budget
yiliang114 Sep 20, 2026
34c379a
Merge origin/main into fix/mcp-image-context-budget
yiliang114 Sep 20, 2026
b307d15
fix(core): skip MCP image bounding when omni delivery owns the media
yiliang114 Sep 20, 2026
fee8cdf
fix(core): bound images the omni funnel keeps inline
yiliang114 Sep 21, 2026
78f1324
test(core): pin the omni gate predicate against isOmniEnabled drift
yiliang114 Sep 21, 2026
bc57a24
fix(core): bound MCP images by the inline byte ceiling, drop the omni…
yiliang114 Sep 21, 2026
37e0b9e
Merge remote-tracking branch 'origin/main' into fix/mcp-image-context…
yiliang114 Sep 21, 2026
636d410
Merge branch 'main' into fix/mcp-image-context-budget
yiliang114 Sep 23, 2026
c01b82b
fix(core): exempt MCP image withholding clamps under omni delivery
yiliang114 Sep 23, 2026
6ad43c9
fix(core): bound the MCP images the omni funnel declines to upload
yiliang114 Sep 23, 2026
75fcc4e
refactor(core): trim MCP image bounding to its core
yiliang114 Sep 23, 2026
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
102 changes: 102 additions & 0 deletions packages/core/src/tools/mcp-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

/* eslint-disable @typescript-eslint/no-explicit-any */
import sharp from 'sharp';
import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
Expand Down Expand Up @@ -724,6 +725,107 @@ describe('DiscoveredMCPTool', () => {
);
});

it('bounds an oversized image returned by an MCP tool', async () => {
const oversized = await sharp({
create: {
width: 3840,
height: 2160,
channels: 3,
background: '#204080',
},
})
.png()
.toBuffer();
mockCallTool.mockResolvedValue([
{
functionResponse: {
name: serverToolName,
response: {
content: [
{
type: 'image',
data: oversized.toString('base64'),
mimeType: 'image/png',
},
],
},
},
},
] as Part[]);

const invocation = tool.build({ param: 'screenshot' });
const toolResult = await invocation.execute(new AbortController().signal);

const parts = toolResult.llmContent as Part[];
const inline = parts[1]!.inlineData!;
expect(inline.mimeType).toBe('image/jpeg');
const bounded = await sharp(
Buffer.from(inline.data!, 'base64'),
).metadata();
expect(Math.max(bounded.width, bounded.height)).toBeLessThanOrEqual(1568);
expect(
Math.ceil(bounded.width / 28) * Math.ceil(bounded.height / 28),
).toBeLessThanOrEqual(1568);
});

it('leaves an in-budget image from an MCP tool untouched', async () => {
const small = await sharp({
create: { width: 200, height: 100, channels: 4, background: '#204080' },
})
.png()
.toBuffer();
const data = small.toString('base64');
mockCallTool.mockResolvedValue([
{
functionResponse: {
name: serverToolName,
response: {
content: [{ type: 'image', data, mimeType: 'image/png' }],
},
},
},
] as Part[]);

const invocation = tool.build({ param: 'icon' });
const toolResult = await invocation.execute(new AbortController().signal);

const parts = toolResult.llmContent as Part[];
expect(parts[1]!.inlineData).toEqual({ mimeType: 'image/png', data });
});

it('forwards an image the renderer cannot bound unchanged', async () => {
const animated = await sharp({
create: {
width: 3840,
height: 2160,
channels: 3,
background: '#204080',
},
})
.gif()
.toBuffer();
const data = animated.toString('base64');
mockCallTool.mockResolvedValue([
{
functionResponse: {
name: serverToolName,
response: {
content: [{ type: 'image', data, mimeType: 'image/gif' }],
},
},
},
] as Part[]);

const invocation = tool.build({ param: 'gif' });
const toolResult = await invocation.execute(new AbortController().signal);

const parts = toolResult.llmContent as Part[];
expect(parts[1]!.inlineData).toEqual({
mimeType: 'image/gif',
data,
});
});

it('should ignore unknown content block types', async () => {
const params = { param: 'test' };
const sdkResponse: Part[] = [
Expand Down
79 changes: 71 additions & 8 deletions packages/core/src/tools/mcp-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { StructuredToolError, ToolErrorType } from './tool-error.js';
import type { Config } from '../config/config.js';
import { truncateToolOutput } from './truncation.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { boundImageBuffer, ImageViewError } from '../utils/image-view.js';
import { getErrorMessage, isAbortError } from '../utils/errors.js';
import {
getAllMCPServerStatuses,
Expand Down Expand Up @@ -697,13 +698,20 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
);

if (this.isMCPToolError(rawResponseParts)) {
return await this.buildMcpToolError(rawResponseParts, {
name: this.serverToolName,
args: this.params,
});
return await this.buildMcpToolError(
rawResponseParts,
{
name: this.serverToolName,
args: this.params,
},
signal,
);
}

const transformedParts = transformMcpContentToParts(rawResponseParts);
const transformedParts = await boundInlineImageParts(
transformMcpContentToParts(rawResponseParts),
signal,
);
const truncated = await this.truncateTextParts(transformedParts);
const fallbackText = getDisplayFromPartsWithPersistedOutput(
Comment thread
yiliang114 marked this conversation as resolved.
Outdated
transformedParts,
Expand Down Expand Up @@ -863,10 +871,17 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
const rawResponseParts = outcome;

if (this.isMCPToolError(rawResponseParts)) {
return await this.buildMcpToolError(rawResponseParts, functionCalls[0]);
return await this.buildMcpToolError(
rawResponseParts,
functionCalls[0],
signal,
);
}

const transformedParts = transformMcpContentToParts(rawResponseParts);
const transformedParts = await boundInlineImageParts(
transformMcpContentToParts(rawResponseParts),
signal,
);
const truncated = await this.truncateTextParts(transformedParts);

return {
Expand All @@ -893,13 +908,16 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
private async buildMcpToolError(
rawResponseParts: Part[],
functionCall: FunctionCall,
signal: AbortSignal,
): Promise<ToolResult> {
const imageContent = getMcpErrorImageContent(rawResponseParts);
let llmContent: PartListUnion;
let errorMessage: string;
let persistedOutputFiles: string[] | undefined;
if (imageContent) {
const truncatedContent = await this.truncateTextParts(imageContent);
const truncatedContent = await this.truncateTextParts(
await boundInlineImageParts(imageContent, signal),
);
llmContent = truncatedContent.parts;
persistedOutputFiles = truncatedContent.persistedOutputFiles;
errorMessage = `MCP tool '${
Expand Down Expand Up @@ -1284,6 +1302,51 @@ function transformImageAudioBlock(
];
}

/**
* Shrink oversized inline images to the same visual budget `read_file`
* applies, so a full-resolution screenshot from a browser automation server
* does not enter the conversation verbatim. Images that already fit, and any
* the renderer cannot handle, are forwarded unchanged.
*/
async function boundInlineImageParts(
parts: Part[],
signal: AbortSignal,
): Promise<Part[]> {
Comment thread
yiliang114 marked this conversation as resolved.
return Promise.all(
Comment thread
yiliang114 marked this conversation as resolved.
Outdated
parts.map(async (part) => {
const inline = part.inlineData;
if (!inline?.data || !inline.mimeType?.startsWith('image/')) {
return part;
}
try {
const view = await boundImageBuffer(
Buffer.from(inline.data, 'base64'),
inline.mimeType,
signal,
);
if (!view) {
return part;
}
return {
inlineData: {
...inline,
data: view.bytes.toString('base64'),
mimeType: view.mimeType,
},
};
Comment thread
yiliang114 marked this conversation as resolved.
} catch (error) {
if (error instanceof ImageViewError) {
debugLogger.debug(
`Forwarding MCP image unbounded: ${getErrorMessage(error)}`,
);
return part;
}
throw error;
}
}),
);
}

function transformResourceBlock(
block: McpResourceBlock,
toolName: string,
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/utils/image-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from 'node:path';
import sharp from 'sharp';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
boundImageBuffer,
orientedSize,
renderImageOverview,
renderNormalizedImageCrop,
Expand Down Expand Up @@ -112,6 +113,50 @@ describe('image views', () => {
expect(view.bytes.length).toBeLessThanOrEqual(9 * 1024 * 1024);
});

it('leaves an in-budget image buffer untouched', async () => {
const bytes = await sharp({
create: { width: 200, height: 100, channels: 3, background: '#306090' },
})
.png()
.toBuffer();

await expect(boundImageBuffer(bytes, 'image/png', signal)).resolves.toBe(
null,
);
});

it('bounds an oversized image buffer to the shared budget', async () => {
const bytes = await sharp({
create: { width: 3840, height: 2160, channels: 3, background: '#804020' },
})
.png()
.toBuffer();

const view = await boundImageBuffer(bytes, 'image/png', signal);

expect(view).not.toBe(null);
expect(view!.mimeType).toBe('image/jpeg');
expect(Math.max(view!.outputWidth, view!.outputHeight)).toBeLessThanOrEqual(
1568,
);
expect(
Math.ceil(view!.outputWidth / 28) * Math.ceil(view!.outputHeight / 28),
).toBeLessThanOrEqual(1568);
expect(view!.bytes.length).toBeLessThan(bytes.length);
});

it('reports unsupported_image for a format the renderer cannot bound', async () => {
const bytes = await sharp({
create: { width: 3840, height: 2160, channels: 3, background: '#804020' },
})
.gif()
.toBuffer();

await expect(
boundImageBuffer(bytes, 'image/gif', signal),
).rejects.toMatchObject({ code: 'unsupported_image' });
});

it('reports decode_failed for a corrupt canonical image', async () => {
const filePath = path.join(root, 'corrupt.png');
await fs.writeFile(filePath, 'not a real png');
Expand Down
Loading
Loading