Skip to content

Commit 1cecc36

Browse files
authored
🤖 fix: truncate oversized MCP images to prevent context overflow (#990)
Large base64-encoded images from MCP servers (like chrome-devtools-mcp screenshots) were overflowing the model's context window. ## Changes ### Image Overflow Fix - Extract `mcpResultTransform` module with 256KB limit for image data - Oversized images replaced with descriptive text message showing the size - Add comprehensive tests for image truncation logic ### CLI MCP Support - Add MCP server support to `mux run` CLI command - Add `--mcp` option for inline server specification: `mux run --mcp "name=command"` - Can be repeated: `--mcp server1=cmd1 --mcp server2=cmd2` - Add `--no-mcp-config` to ignore `.mux/mcp.jsonc` file ## Examples ```bash # Use config file servers (default) mux run "Take a screenshot" # Add inline servers (merged with config) mux run --mcp "memory=npx -y @modelcontextprotocol/server-memory" "Remember this" # Use only inline servers (ignore config file) mux run --no-mcp-config --mcp "chrome=npx chrome-devtools-mcp" "Screenshot google.com" ``` ## Testing - Unit tests: 10 new tests for image truncation - CLI tests: 6 new tests for `--mcp` argument parsing - E2E: Verified with real chrome MCP server _Generated with `mux`_
1 parent f83994e commit 1cecc36

File tree

5 files changed

+411
-90
lines changed

5 files changed

+411
-90
lines changed

src/cli/run.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,65 @@ describe("mux CLI", () => {
169169
expect(result.exitCode).toBe(1);
170170
expect(result.output.length).toBeGreaterThan(0);
171171
});
172+
173+
test("--help shows --mcp option", async () => {
174+
const result = await runCli(["run", "--help"]);
175+
expect(result.exitCode).toBe(0);
176+
expect(result.stdout).toContain("--mcp");
177+
expect(result.stdout).toContain("name=command");
178+
expect(result.stdout).toContain("--no-mcp-config");
179+
});
180+
181+
test("--mcp without = shows error", async () => {
182+
const result = await runRunDirect(["--mcp", "invalid-format", "test message"]);
183+
expect(result.exitCode).toBe(1);
184+
expect(result.output).toContain("Invalid --mcp format");
185+
expect(result.output).toContain("Expected: name=command");
186+
});
187+
188+
test("--mcp with empty name shows error", async () => {
189+
const result = await runRunDirect(["--mcp", "=some-command", "test message"]);
190+
expect(result.exitCode).toBe(1);
191+
expect(result.output).toContain("Server name is required");
192+
});
193+
194+
test("--mcp with empty command shows error", async () => {
195+
const result = await runRunDirect(["--mcp", "myserver=", "test message"]);
196+
expect(result.exitCode).toBe(1);
197+
expect(result.output).toContain("Command is required");
198+
});
199+
200+
test("--mcp accepts valid name=command format", async () => {
201+
// Test with a nonexistent directory to ensure parsing succeeds before failing
202+
const result = await runRunDirect([
203+
"--dir",
204+
"/nonexistent/path/for/mcp/test",
205+
"--mcp",
206+
"memory=npx -y @modelcontextprotocol/server-memory",
207+
"test message",
208+
]);
209+
// Should not fail with "Invalid --mcp format" - will fail on directory instead
210+
expect(result.output).not.toContain("Invalid --mcp format");
211+
// Verify it got past argument parsing to directory validation
212+
expect(result.exitCode).toBe(1);
213+
});
214+
215+
test("--mcp can be repeated multiple times", async () => {
216+
// Test with a nonexistent directory to ensure parsing succeeds before failing
217+
const result = await runRunDirect([
218+
"--dir",
219+
"/nonexistent/path/for/mcp/test",
220+
"--mcp",
221+
"server1=command1",
222+
"--mcp",
223+
"server2=command2 with args",
224+
"test message",
225+
]);
226+
// Should not fail with "Invalid --mcp format"
227+
expect(result.output).not.toContain("Invalid --mcp format");
228+
// Verify it got past argument parsing to directory validation
229+
expect(result.exitCode).toBe(1);
230+
});
172231
});
173232

174233
describe("mux server", () => {

src/cli/run.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { InitStateManager } from "@/node/services/initStateManager";
1818
import { AIService } from "@/node/services/aiService";
1919
import { AgentSession, type AgentSessionChatEvent } from "@/node/services/agentSession";
2020
import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
21+
import { MCPConfigService } from "@/node/services/mcpConfigService";
22+
import { MCPServerManager } from "@/node/services/mcpServerManager";
2123
import {
2224
isCaughtUpMessage,
2325
isStreamAbort,
@@ -153,6 +155,27 @@ function renderUnknown(value: unknown): string {
153155
}
154156
}
155157

158+
interface MCPServerEntry {
159+
name: string;
160+
command: string;
161+
}
162+
163+
function collectMcpServers(value: string, previous: MCPServerEntry[]): MCPServerEntry[] {
164+
const eqIndex = value.indexOf("=");
165+
if (eqIndex === -1) {
166+
throw new Error(`Invalid --mcp format: "${value}". Expected: name=command`);
167+
}
168+
const name = value.slice(0, eqIndex).trim();
169+
const command = value.slice(eqIndex + 1).trim();
170+
if (!name) {
171+
throw new Error(`Invalid --mcp format: "${value}". Server name is required`);
172+
}
173+
if (!command) {
174+
throw new Error(`Invalid --mcp format: "${value}". Command is required`);
175+
}
176+
return [...previous, { name, command }];
177+
}
178+
156179
const program = new Command();
157180

158181
program
@@ -171,6 +194,8 @@ program
171194
.option("-q, --quiet", "only output final result")
172195
.option("--workspace-id <id>", "explicit workspace ID (auto-generated if not provided)")
173196
.option("--config-root <path>", "mux config directory")
197+
.option("--mcp <server>", "MCP server as name=command (can be repeated)", collectMcpServers, [])
198+
.option("--no-mcp-config", "ignore .mux/mcp.jsonc, use only --mcp servers")
174199
.addHelpText(
175200
"after",
176201
`
@@ -181,6 +206,8 @@ Examples:
181206
$ mux run --mode plan "Refactor the auth module"
182207
$ echo "Add logging" | mux run
183208
$ mux run --json "List all files" | jq '.type'
209+
$ mux run --mcp "memory=npx -y @modelcontextprotocol/server-memory" "Remember this"
210+
$ mux run --mcp "chrome=npx chrome-devtools-mcp" --mcp "fs=npx @anthropic/mcp-fs" "Take a screenshot"
184211
`
185212
);
186213

@@ -199,6 +226,8 @@ interface CLIOptions {
199226
quiet?: boolean;
200227
workspaceId?: string;
201228
configRoot?: string;
229+
mcp: MCPServerEntry[];
230+
mcpConfig: boolean;
202231
}
203232

204233
const opts = program.opts<CLIOptions>();
@@ -278,6 +307,18 @@ async function main(): Promise<void> {
278307
);
279308
ensureProvidersConfig(config);
280309

310+
// Initialize MCP support
311+
const mcpConfigService = new MCPConfigService();
312+
const inlineServers: Record<string, string> = {};
313+
for (const entry of opts.mcp) {
314+
inlineServers[entry.name] = entry.command;
315+
}
316+
const mcpServerManager = new MCPServerManager(mcpConfigService, {
317+
inlineServers,
318+
ignoreConfigFile: !opts.mcpConfig,
319+
});
320+
aiService.setMCPServerManager(mcpServerManager);
321+
281322
const session = new AgentSession({
282323
workspaceId,
283324
config,
@@ -523,6 +564,7 @@ async function main(): Promise<void> {
523564
} finally {
524565
unsubscribe();
525566
session.dispose();
567+
mcpServerManager.dispose();
526568
}
527569
}
528570

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { transformMCPResult, MAX_IMAGE_DATA_BYTES } from "./mcpResultTransform";
3+
4+
describe("transformMCPResult", () => {
5+
describe("image data overflow handling", () => {
6+
it("should pass through small images unchanged", () => {
7+
const smallImageData = "a".repeat(1000); // 1KB of base64 data
8+
const result = transformMCPResult({
9+
content: [
10+
{ type: "text", text: "Screenshot taken" },
11+
{ type: "image", data: smallImageData, mimeType: "image/png" },
12+
],
13+
});
14+
15+
expect(result).toEqual({
16+
type: "content",
17+
value: [
18+
{ type: "text", text: "Screenshot taken" },
19+
{ type: "media", data: smallImageData, mediaType: "image/png" },
20+
],
21+
});
22+
});
23+
24+
it("should truncate large image data to prevent context overflow", () => {
25+
// Create a large base64 string that simulates a big screenshot
26+
// A typical screenshot could be 500KB-2MB of base64 data
27+
const largeImageData = "x".repeat(MAX_IMAGE_DATA_BYTES + 100_000);
28+
const result = transformMCPResult({
29+
content: [
30+
{ type: "text", text: "Screenshot taken" },
31+
{ type: "image", data: largeImageData, mimeType: "image/png" },
32+
],
33+
});
34+
35+
const transformed = result as {
36+
type: "content";
37+
value: Array<{ type: string; text?: string; data?: string; mediaType?: string }>;
38+
};
39+
40+
expect(transformed.type).toBe("content");
41+
expect(transformed.value).toHaveLength(2);
42+
expect(transformed.value[0]).toEqual({ type: "text", text: "Screenshot taken" });
43+
44+
// The image should be replaced with a text message explaining the truncation
45+
const imageResult = transformed.value[1];
46+
expect(imageResult.type).toBe("text");
47+
expect(imageResult.text).toContain("Image data too large");
48+
expect(imageResult.text).toContain(String(largeImageData.length));
49+
});
50+
51+
it("should handle multiple images, truncating only the oversized ones", () => {
52+
const smallImageData = "small".repeat(100);
53+
const largeImageData = "x".repeat(MAX_IMAGE_DATA_BYTES + 50_000);
54+
55+
const result = transformMCPResult({
56+
content: [
57+
{ type: "image", data: smallImageData, mimeType: "image/png" },
58+
{ type: "image", data: largeImageData, mimeType: "image/jpeg" },
59+
],
60+
});
61+
62+
const transformed = result as {
63+
type: "content";
64+
value: Array<{ type: string; text?: string; data?: string; mediaType?: string }>;
65+
};
66+
67+
expect(transformed.value).toHaveLength(2);
68+
// Small image passes through
69+
expect(transformed.value[0]).toEqual({
70+
type: "media",
71+
data: smallImageData,
72+
mediaType: "image/png",
73+
});
74+
// Large image gets truncated with explanation
75+
expect(transformed.value[1].type).toBe("text");
76+
expect(transformed.value[1].text).toContain("Image data too large");
77+
});
78+
79+
it("should report approximate file size in KB/MB in truncation message", () => {
80+
// ~1.5MB of base64 data
81+
const largeImageData = "y".repeat(1_500_000);
82+
const result = transformMCPResult({
83+
content: [{ type: "image", data: largeImageData, mimeType: "image/png" }],
84+
});
85+
86+
const transformed = result as {
87+
type: "content";
88+
value: Array<{ type: string; text?: string }>;
89+
};
90+
91+
expect(transformed.value[0].type).toBe("text");
92+
// Should mention MB since it's over 1MB
93+
expect(transformed.value[0].text).toMatch(/\d+(\.\d+)?\s*MB/i);
94+
});
95+
});
96+
97+
describe("existing functionality", () => {
98+
it("should pass through error results unchanged", () => {
99+
const errorResult = {
100+
isError: true,
101+
content: [{ type: "text" as const, text: "Error!" }],
102+
};
103+
expect(transformMCPResult(errorResult)).toBe(errorResult);
104+
});
105+
106+
it("should pass through toolResult unchanged", () => {
107+
const toolResult = { toolResult: { foo: "bar" } };
108+
expect(transformMCPResult(toolResult)).toBe(toolResult);
109+
});
110+
111+
it("should pass through results without content array", () => {
112+
const noContent = { something: "else" };
113+
expect(transformMCPResult(noContent as never)).toBe(noContent);
114+
});
115+
116+
it("should pass through text-only content without transformation wrapper", () => {
117+
const textOnly = {
118+
content: [
119+
{ type: "text" as const, text: "Hello" },
120+
{ type: "text" as const, text: "World" },
121+
],
122+
};
123+
// No images = no transformation needed
124+
expect(transformMCPResult(textOnly)).toBe(textOnly);
125+
});
126+
127+
it("should convert resource content to text", () => {
128+
const result = transformMCPResult({
129+
content: [
130+
{ type: "image", data: "abc", mimeType: "image/png" },
131+
{ type: "resource", resource: { uri: "file:///test.txt", text: "File content" } },
132+
],
133+
});
134+
135+
const transformed = result as {
136+
type: "content";
137+
value: Array<{ type: string; text?: string; data?: string }>;
138+
};
139+
140+
expect(transformed.value[1]).toEqual({ type: "text", text: "File content" });
141+
});
142+
143+
it("should default to image/png when mimeType is missing", () => {
144+
const result = transformMCPResult({
145+
content: [{ type: "image", data: "abc", mimeType: "" }],
146+
});
147+
148+
const transformed = result as {
149+
type: "content";
150+
value: Array<{ type: string; mediaType?: string }>;
151+
};
152+
153+
expect(transformed.value[0].mediaType).toBe("image/png");
154+
});
155+
});
156+
});

0 commit comments

Comments
 (0)