Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/coding-agent/docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion packages/coding-agent/src/core/mcp-servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`;
Expand All @@ -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")) {
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/extensions/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 22 additions & 13 deletions packages/coding-agent/src/extensions/mcp/tools.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -118,15 +119,16 @@ export function createMcpResultSchema(structuredContentSchema: Record<string, un
}

/**
* Keep model-facing text within {@link MCP_OUTPUT_MAX_BYTES}. Longer text becomes one text block in
* Codex's truncation format, followed by the path of the file with the full text; images follow it.
* Keep model-facing text within `maxBytes`. Longer text becomes one text block in Codex's truncation
* format, followed by the path of the file with the full text; images follow it.
*/
export async function limitMcpContent(
content: (TextContent | ImageContent)[],
saveOutput: McpOutputSaver = saveToTempFile,
maxBytes = MCP_OUTPUT_MAX_BYTES,
): Promise<{ content: (TextContent | ImageContent)[]; fullOutputPath?: string }> {
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;
Expand All @@ -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`. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -263,6 +267,8 @@ export function createMcpToolDefinition(options: {
getClient: () => Promise<McpToolCaller>;
/** Whether `read_mcp_resource` can read the server's resources. */
readableResources?: () => boolean;
/** The server's `maxOutputBytes`. */
maxOutputBytes?: number;
}): ToolDefinition<TSchema, McpToolDetails> {
const { server, tool } = options;
const title = tool.title ?? tool.annotations?.title;
Expand All @@ -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,
});
},
};
}
Expand Down
54 changes: 53 additions & 1 deletion packages/coding-agent/test/mcp-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<typeof definition.execute>[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);
Expand Down
Loading