From 0a52fdaf71ea218328f0d10d4d827de1ace423e8 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:15:14 +0000 Subject: [PATCH] feat(coding-agent): per-server maxOutputBytes for MCP tool results --- packages/coding-agent/docs/mcp.md | 1 + packages/coding-agent/src/core/mcp-servers.ts | 10 +++- .../coding-agent/src/extensions/mcp/index.ts | 1 + .../coding-agent/src/extensions/mcp/tools.ts | 35 +++++++----- .../coding-agent/test/mcp-extension.test.ts | 54 ++++++++++++++++++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md index c6e160444644..ab851717abc4 100644 --- a/packages/coding-agent/docs/mcp.md +++ b/packages/coding-agent/docs/mcp.md @@ -66,6 +66,7 @@ HTTP servers use `url`, `headers`, and `oauth` (see [Authenticate with OAuth](#a Both server types support: - `timeout`: per-request timeout in seconds (default 60). Progress notifications reset it. +- `maxOutputBytes`: how much text of a tool result the model sees (default 20480). Longer text keeps its start and end, and the full text is saved to a file the model can read with the `read` tool. Raise it when the model cannot read files, or when a tool returns long results it needs whole. - `enabled: false`: keep the entry without connecting to it. - `exposure` and `toolExposure`: control how tools reach the model (see [Control tool exposure](#control-tool-exposure)). - `description`: what the server offers, in a sentence. It lists the server in the system prompt (see [Control tool exposure](#control-tool-exposure)), tool search ranks the server's tools by it, and codemode's `describeNamespace()` returns it. Without it, the first line of the server instructions is used once the server connects. diff --git a/packages/coding-agent/src/core/mcp-servers.ts b/packages/coding-agent/src/core/mcp-servers.ts index 1f6414928f37..cb6cdec1dfc3 100644 --- a/packages/coding-agent/src/core/mcp-servers.ts +++ b/packages/coding-agent/src/core/mcp-servers.ts @@ -75,6 +75,11 @@ interface McpServerConfigBase { enabled?: boolean; /** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */ timeout?: number; + /** + * Bytes of model-facing text a tool result may have. Longer text is cut in the middle and saved to + * a file. Default: 20480. + */ + maxOutputBytes?: number; } export interface McpStdioServerConfig extends McpServerConfigBase { @@ -249,7 +254,7 @@ export function validateMcpServerConfig(name: string, raw: unknown): McpServerCo if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`; if (!isRecord(raw)) return `server "${name}" must be an object`; const value = resolveExposureAliases(raw); - const { type, exposure, enabled, timeout, toolExposure, description } = value; + const { type, exposure, enabled, timeout, maxOutputBytes, toolExposure, description } = value; const exposures = MCP_EXPOSURES.map((value) => `"${value}"`).join(", "); if (exposure !== undefined && !isExposure(exposure)) { return `server "${name}": exposure must be one of ${exposures}`; @@ -267,6 +272,9 @@ export function validateMcpServerConfig(name: string, raw: unknown): McpServerCo if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) { return `server "${name}": timeout must be a positive number of seconds`; } + if (maxOutputBytes !== undefined && !(Number.isInteger(maxOutputBytes) && (maxOutputBytes as number) > 0)) { + return `server "${name}": maxOutputBytes must be a positive integer`; + } if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`; if (typeof value.url === "string" && (type === undefined || type === "http" || type === "streamable-http")) { diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts index 43e2ecebed36..2508f7728e7a 100644 --- a/packages/coding-agent/src/extensions/mcp/index.ts +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -422,6 +422,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension exposure: getMcpToolExposure(entry.config, tool.name), namespace, timeoutMs: connection.timeoutMs, + maxOutputBytes: entry.config.maxOutputBytes, getClient: async () => { // A prepared call can outlive this definition's connection while the readiness // hook waits for a disable/re-enable. Resolve the replacement at execution time. diff --git a/packages/coding-agent/src/extensions/mcp/tools.ts b/packages/coding-agent/src/extensions/mcp/tools.ts index ca4da96b0932..dc803232620a 100644 --- a/packages/coding-agent/src/extensions/mcp/tools.ts +++ b/packages/coding-agent/src/extensions/mcp/tools.ts @@ -1,13 +1,14 @@ /** * Adapts MCP tools to pi tool definitions. * - * Results map onto pi's model-facing content (text and images). Text over 20KB keeps its start and - * end with the middle cut out, like Codex does, and the full text is saved to a temp file the model - * can read. Binary resources other than images are saved to temp files too, and resource links name - * the `read_mcp_resource` tool. Codemode scripts receive the whole `CallToolResult` without `_meta` - * (`content` blocks as sent by the server, `structuredContent`, `isError`), never truncated: it is - * the tool's `structuredContent`, and every MCP tool declares a `CallToolResult` output schema. MCP - * errors (`isError`) are error results for the model, but scripts still resolve to the result. + * Results map onto pi's model-facing content (text and images). Text over 20KB, or the server's + * `maxOutputBytes`, keeps its start and end with the middle cut out, like Codex does, and the full + * text is saved to a temp file the model can read. Binary resources other than images are saved to + * temp files too, and resource links name the `read_mcp_resource` tool. Codemode scripts receive + * the whole `CallToolResult` without `_meta` (`content` blocks as sent by the server, + * `structuredContent`, `isError`), never truncated: it is the tool's `structuredContent`, and every + * MCP tool declares a `CallToolResult` output schema. MCP errors (`isError`) are error results for + * the model, but scripts still resolve to the result. */ import { createHash } from "node:crypto"; @@ -47,7 +48,7 @@ export function toToolExposure(exposure: McpExposure): ToolExposure { /** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */ const MAX_TOOL_NAME_LENGTH = 64; -/** Model-facing text of an MCP result beyond this is cut in the middle. */ +/** Model-facing text of an MCP result beyond this is cut in the middle. Servers can set `maxOutputBytes`. */ export const MCP_OUTPUT_MAX_BYTES = 20 * 1024; /** Visual (wrapped) result lines shown before the output is expanded. */ const OUTPUT_PREVIEW_LINES = 5; @@ -118,15 +119,16 @@ export function createMcpResultSchema(structuredContentSchema: Record { const combined = textOf(content); - const truncation = truncateMiddle(combined, MCP_OUTPUT_MAX_BYTES); + const truncation = truncateMiddle(combined, maxBytes); if (!truncation.truncated) return { content }; let fullOutputPath: string | undefined; let where: string; @@ -149,6 +151,8 @@ export interface ConvertMcpResultOptions { saveOutput?: McpOutputSaver; /** Whether the server's resources can be read with `read_mcp_resource`, which resource links then name. */ readableResources?: boolean; + /** Bytes of model-facing text kept before the middle is cut. Default: {@link MCP_OUTPUT_MAX_BYTES}. */ + maxOutputBytes?: number; } /** File extension for a saved binary resource: the one its URI ends in, else `.bin`. */ @@ -219,7 +223,7 @@ export async function convertMcpResult( if (result.isError && textOf(converted) === "") { converted.push({ type: "text", text: `MCP tool ${server}/${tool} returned an error` }); } - const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput); + const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput, options.maxOutputBytes); const { _meta: _ignored, ...scriptResult } = result; return { content, @@ -263,6 +267,8 @@ export function createMcpToolDefinition(options: { getClient: () => Promise; /** Whether `read_mcp_resource` can read the server's resources. */ readableResources?: () => boolean; + /** The server's `maxOutputBytes`. */ + maxOutputBytes?: number; }): ToolDefinition { const { server, tool } = options; const title = tool.title ?? tool.annotations?.title; @@ -289,7 +295,10 @@ export function createMcpToolDefinition(options: { onUpdate?.({ content: [{ type: "text", text }], details: { server, tool: tool.name } }); }, }); - return convertMcpResult(server, tool.name, result, { readableResources: options.readableResources?.() }); + return convertMcpResult(server, tool.name, result, { + readableResources: options.readableResources?.(), + maxOutputBytes: options.maxOutputBytes, + }); }, }; } diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index 91291ee794aa..28bf3852ee00 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -27,7 +27,7 @@ import { McpServerConnection, McpServerLog, } from "../src/extensions/mcp/runtime.ts"; -import { convertMcpResult, createMcpToolName } from "../src/extensions/mcp/tools.ts"; +import { convertMcpResult, createMcpToolDefinition, createMcpToolName } from "../src/extensions/mcp/tools.ts"; // Config values are resolved at connect time, so the literal reference must survive loading. // biome-ignore lint/suspicious/noTemplateCurlyInString: literal config value reference @@ -85,6 +85,27 @@ describe("MCP config", () => { expect(untrusted.servers.find((server) => server.name === "shared")?.config).toEqual({ command: "global-cmd" }); }); + it("accepts a positive integer maxOutputBytes", () => { + const paths = setup( + { + mcpServers: { + big: { command: "x", maxOutputBytes: 100_000 }, + zero: { command: "x", maxOutputBytes: 0 }, + fraction: { command: "x", maxOutputBytes: 1.5 }, + }, + }, + {}, + ); + const config = loadMcpConfig({ ...paths, projectTrusted: false }); + expect(config.servers.map((server) => [server.name, server.config])).toEqual([ + ["big", { command: "x", maxOutputBytes: 100_000 }], + ]); + expect(config.errors).toEqual([ + expect.stringContaining('server "zero": maxOutputBytes must be a positive integer'), + expect.stringContaining('server "fraction": maxOutputBytes must be a positive integer'), + ]); + }); + // #10277 it("lets project entries override enabled and exposure of global servers", () => { const paths = setup( @@ -388,6 +409,37 @@ describe("MCP tools", () => { expect(saved).toHaveLength(1); }); + it("keeps model-facing text up to the server's maxOutputBytes", async () => { + const full = Array.from({ length: 3000 }, (_, index) => `line ${index + 1}`).join("\n"); + const definition = createMcpToolDefinition({ + server: "docs", + tool: { name: "snapshot", inputSchema: { type: "object" } }, + name: "mcp__docs__snapshot", + exposure: "direct", + namespace: { name: "mcp__docs" }, + timeoutMs: 1000, + maxOutputBytes: 64 * 1024, + getClient: async () => ({ callTool: async () => ({ content: [{ type: "text", text: full }] }) }), + }); + const result = await definition.execute( + "call", + {}, + undefined, + undefined, + {} as Parameters[4], + ); + expect(result.content).toEqual([{ type: "text", text: full }]); + expect(result.details).toEqual({ server: "docs", tool: "snapshot" }); + + const limited = await convertMcpResult( + "docs", + "snapshot", + { content: [{ type: "text", text: full }] }, + { saveOutput: async () => "/tmp/full.txt", maxOutputBytes: 1024 }, + ); + expect(Buffer.byteLength((limited.content[0] as { text: string }).text)).toBeLessThan(2 * 1024); + }); + it("cuts multi-byte text only at character boundaries", () => { const text = `${"é".repeat(20_000)}end`; const result = truncateMiddle(text, 1001);