From 76c040a2ad4b4f940feb16e23dae77010a4b25a1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 3 Nov 2026 01:51:23 +0100 Subject: [PATCH] feat(mcp): shared tool naming, schema, output limit and config helpers createMcpToolName, mcpNamespace, normalizeMcpInputSchema, limitMcpContent (with a caller-provided saver) and validation of the standard mcpServers shape move from coding-agent into pi-mcp, so other applications name and limit MCP tools exactly like pi. Applications validate their own fields on the tagged result; coding-agent keeps exposure, auth.provider and its Client ID Metadata Document rule. --- .../src/core/extensions/loader.ts | 3 +- packages/coding-agent/src/core/mcp-servers.ts | 216 +++++----------- .../coding-agent/src/core/tools/truncate.ts | 38 --- .../coding-agent/src/extensions/mcp/config.ts | 17 +- .../coding-agent/src/extensions/mcp/index.ts | 4 +- .../coding-agent/src/extensions/mcp/oauth.ts | 3 +- .../src/extensions/mcp/resources.ts | 25 +- .../coding-agent/src/extensions/mcp/tools.ts | 78 +----- .../coding-agent/test/mcp-extension.test.ts | 16 +- .../test/suite/agent-session-mcp.test.ts | 3 +- packages/mcp/CHANGELOG.md | 1 + packages/mcp/README.md | 22 +- packages/mcp/src/config.ts | 243 ++++++++++++++++++ packages/mcp/src/index.ts | 18 ++ packages/mcp/src/input-schema.ts | 13 + packages/mcp/src/naming.ts | 35 +++ packages/mcp/src/output-limit.ts | 77 ++++++ packages/mcp/test/config.test.ts | 157 +++++++++++ packages/mcp/test/input-schema.test.ts | 24 ++ packages/mcp/test/naming.test.ts | 37 +++ packages/mcp/test/output-limit.test.ts | 79 ++++++ 21 files changed, 804 insertions(+), 305 deletions(-) create mode 100644 packages/mcp/src/config.ts create mode 100644 packages/mcp/src/input-schema.ts create mode 100644 packages/mcp/src/naming.ts create mode 100644 packages/mcp/src/output-limit.ts create mode 100644 packages/mcp/test/config.test.ts create mode 100644 packages/mcp/test/input-schema.test.ts create mode 100644 packages/mcp/test/naming.test.ts create mode 100644 packages/mcp/test/output-limit.test.ts diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index ff9f127c4..d82270d13 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -8,6 +8,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { Provider } from "@earendil-works/pi-ai"; +import { mcpNamespace } from "@earendil-works/pi-mcp"; import type { KeyId } from "@earendil-works/pi-tui"; import type { createJiti } from "jiti"; import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.ts"; @@ -15,7 +16,7 @@ import { pathsEqual, resolvePath } from "../../utils/paths.ts"; import { createEventBus, type EventBus } from "../event-bus.ts"; import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; -import { type McpServerConfig, McpServerRegistry, mcpNamespace, validateMcpServerConfig } from "../mcp-servers.ts"; +import { type McpServerConfig, McpServerRegistry, validateMcpServerConfig } from "../mcp-servers.ts"; import { readPiManifest } from "../pi-manifest.ts"; import { createSyntheticSourceInfo, getSyntheticPathSource, isSyntheticPath } from "../source-info.ts"; import { time } from "../timings.ts"; diff --git a/packages/coding-agent/src/core/mcp-servers.ts b/packages/coding-agent/src/core/mcp-servers.ts index 46b4241d5..002cf56f5 100644 --- a/packages/coding-agent/src/core/mcp-servers.ts +++ b/packages/coding-agent/src/core/mcp-servers.ts @@ -3,8 +3,17 @@ * * The core only validates and stores registrations. The MCP extension (built in, or another * extension that handles `mcp_servers_change`) connects them next to the servers from `mcp.json`. + * The `mcpServers` shape itself (stdio and HTTP fields, `oauth`, `enabled`, `description`, `timeout`) + * is pi-mcp's; this file adds pi's fields to it. */ +import { + type McpHttpServerConfig as McpHttpServerFields, + type McpOAuthConfig as McpOAuthFields, + type McpStdioServerConfig as McpStdioServerFields, + validateMcpServerConfig as validateMcpServerFields, +} from "@earendil-works/pi-mcp"; + /** * Regular expression for a tool name pattern where `*` matches any characters, as `toolExposure`, * `--tools`, and `--exclude-tools` accept them. @@ -56,6 +65,10 @@ const MCP_EXPOSURES: readonly string[] = ["codemode", "deferred", "direct", "hid /** Older exposure names, accepted in configs and replaced by their current name when validated. */ const MCP_EXPOSURE_ALIASES: Readonly> = { "codemode-deferred": "codemode" }; +/** + * pi's fields of every server entry, beside the shared `mcpServers` fields of pi-mcp. `description` + * and `timeout` are shared fields, declared again for pi's documentation. + */ interface McpServerConfigBase { /** Default: `codemode`. */ exposure?: McpExposure; @@ -71,16 +84,12 @@ interface McpServerConfigBase { * overrides for a few tools exposes only those. */ toolExposure?: Record; - /** Set to false to keep the entry without connecting. Default: true. */ - enabled?: boolean; /** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */ timeout?: number; } -export interface McpStdioServerConfig extends McpServerConfigBase { - type?: "stdio"; - command: string; - args?: string[]; +export interface McpStdioServerConfig extends McpStdioServerFields, McpServerConfigBase { + // Shared fields, declared again for pi's documentation. /** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */ env?: Record; /** Relative paths resolve against the session working directory. */ @@ -88,24 +97,10 @@ export interface McpStdioServerConfig extends McpServerConfigBase { } /** OAuth client settings for servers that do not support dynamic client registration. */ -export interface McpOAuthConfig { - /** Pre-registered client id. Without it, pi registers a client with the authorization server. */ - clientId?: string; +export interface McpOAuthConfig extends McpOAuthFields { + // Shared fields, declared again for pi's documentation. /** May reference environment variables (`${NAME}`) or commands (`!cmd`). */ clientSecret?: string; - /** - * Port of the loopback callback server, for clients registered with a fixed redirect URI. Without - * `callbackUrl`, the redirect URI is `http://127.0.0.1:/callback`. - */ - callbackPort?: number; - /** - * Redirect URI registered for `clientId`, for example `http://localhost:8080/oauth/callback`. It must - * be an `http` URI on `localhost`, `127.0.0.1`, or `[::1]`. Without a port, the callback server - * listens on `callbackPort` or a free port, which is added to the URI (RFC 8252). - */ - callbackUrl?: string; - /** Scopes to request, separated by spaces. Default: the scopes the server advertises. */ - scope?: string; /** * `client_name` sent with dynamic client registration, for servers that only accept known clients. * Default: `pi`. @@ -119,21 +114,6 @@ export interface McpOAuthConfig { * `dcr`: always dynamic client registration. */ clientRegistration?: "auto" | "dcr" | "cimd"; - /** - * Authorization server metadata document (RFC 8414 or OpenID Connect discovery) to use instead of - * discovery through the server, for servers that advertise a wrong authorization server or none. - * The document is trusted as configured. Must use https, except on loopback hosts. - */ - authServerMetadataUrl?: string; -} - -const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; - -/** Whether a redirect URI can be served by pi's loopback callback server. */ -export function isLoopbackRedirectUri(value: string): boolean { - if (!URL.canParse(value)) return false; - const url = new URL(value); - return url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === ""; } /** @@ -146,9 +126,8 @@ export function isClientMetadataDocumentCallback(callbackUrl: string | undefined return url.hostname !== "[::1]" && url.pathname === "/callback"; } -export interface McpHttpServerConfig extends McpServerConfigBase { - type?: "http"; - url: string; +export interface McpHttpServerConfig extends McpHttpServerFields, McpServerConfigBase { + // `headers` is shared, declared again for pi's documentation; `oauth` narrows to pi's documented type. /** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */ headers?: Record; oauth?: McpOAuthConfig; @@ -161,65 +140,10 @@ export interface McpHttpServerConfig extends McpServerConfigBase { export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig; -const SERVER_NAME = /^[A-Za-z0-9_-]+$/; - -/** Namespace of a server's tools: `mcp__` with `-` replaced by `_`, like the tool names. */ -export function mcpNamespace(server: string): string { - return `mcp__${server.replace(/-/g, "_")}`; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isStringRecord(value: unknown): value is Record { - return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); -} - -function validateOAuth(value: unknown): string | undefined { - if (value === undefined) return undefined; - if (!isRecord(value)) return "oauth must be an object"; - if (value.clientId !== undefined && typeof value.clientId !== "string") return "oauth.clientId must be a string"; - if (value.clientSecret !== undefined && typeof value.clientSecret !== "string") { - return "oauth.clientSecret must be a string"; - } - const port = value.callbackPort; - if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { - return "oauth.callbackPort must be a port number"; - } - if (value.callbackUrl !== undefined) { - if (typeof value.callbackUrl !== "string" || !isLoopbackRedirectUri(value.callbackUrl)) { - return "oauth.callbackUrl must be an http URI on localhost, 127.0.0.1, or [::1] without query or fragment"; - } - const urlPort = new URL(value.callbackUrl).port; - if (urlPort && port !== undefined && Number(urlPort) !== port) { - return "oauth.callbackUrl and oauth.callbackPort name different ports"; - } - } - if (value.scope !== undefined && typeof value.scope !== "string") return "oauth.scope must be a string"; - if (value.clientName !== undefined && (typeof value.clientName !== "string" || !value.clientName.trim())) { - return "oauth.clientName must be a non-empty string"; - } - const registration = value.clientRegistration; - if (registration !== undefined && registration !== "auto" && registration !== "dcr") { - if (registration !== "cimd") return 'oauth.clientRegistration must be "auto", "dcr", or "cimd"'; - if (value.clientId !== undefined || value.clientName !== undefined) { - return 'oauth.clientRegistration "cimd" cannot be combined with oauth.clientId or oauth.clientName'; - } - if (!isClientMetadataDocumentCallback(typeof value.callbackUrl === "string" ? value.callbackUrl : undefined)) { - return 'oauth.clientRegistration "cimd" requires oauth.callbackUrl on localhost or 127.0.0.1 with path /callback'; - } - } - const metadataUrl = value.authServerMetadataUrl; - if (metadataUrl !== undefined) { - const url = typeof metadataUrl === "string" && URL.canParse(metadataUrl) ? new URL(metadataUrl) : undefined; - if (!url || !(url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)))) { - return "oauth.authServerMetadataUrl must be an https URL, or http on localhost, 127.0.0.1, or [::1]"; - } - } - return undefined; -} - function isExposure(value: unknown): value is McpExposure { return typeof value === "string" && MCP_EXPOSURES.includes(value); } @@ -253,67 +177,53 @@ export function getMcpToolExposure(config: McpServerConfig, toolName: string): M return config.exposure ?? "codemode"; } +/** Checks `exposure` and `toolExposure`, after aliases were resolved. Returns an error message. */ +function checkExposures(entry: Record): string | undefined { + const { exposure, toolExposure } = entry; + const exposures = MCP_EXPOSURES.map((value) => `"${value}"`).join(", "); + if (exposure !== undefined && !isExposure(exposure)) return `exposure must be one of ${exposures}`; + if (toolExposure !== undefined) { + if (!isRecord(toolExposure)) return "toolExposure must map tool names to exposures"; + for (const [tool, value] of Object.entries(toolExposure)) { + if (!isExposure(value)) return `toolExposure "${tool}" must be one of ${exposures}`; + } + } + return undefined; +} + +const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; + +/** Checks `auth` of an HTTP server, which sends a provider's credential to `url`. Returns an error message. */ +function checkAuth(auth: unknown, url: string): string | undefined { + if (auth === undefined) return undefined; + if (!isRecord(auth) || typeof auth.provider !== "string" || !auth.provider) { + return "auth.provider must be a provider name"; + } + const parsed = new URL(url); + if (parsed.protocol !== "https:" && !LOOPBACK_HOSTS.includes(parsed.hostname)) { + return "auth requires an https URL, or http on localhost, 127.0.0.1, or [::1]"; + } + return undefined; +} + /** - * Validate one server entry of the `mcpServers` shape. Returns a copy of the config with exposure - * aliases resolved, or an error message. + * Validate one server entry of the `mcpServers` shape: pi-mcp's shared fields and pi's own. Returns a + * copy of the config with exposure aliases resolved, or an error message. */ export function validateMcpServerConfig(name: string, raw: unknown): McpServerConfig | string { - 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 exposures = MCP_EXPOSURES.map((value) => `"${value}"`).join(", "); - if (exposure !== undefined && !isExposure(exposure)) { - return `server "${name}": exposure must be one of ${exposures}`; - } - if (toolExposure !== undefined) { - if (!isRecord(toolExposure)) return `server "${name}": toolExposure must map tool names to exposures`; - for (const [tool, value] of Object.entries(toolExposure)) { - if (!isExposure(value)) return `server "${name}": toolExposure "${tool}" must be one of ${exposures}`; - } - } - if (enabled !== undefined && typeof enabled !== "boolean") return `server "${name}": enabled must be a boolean`; - if (description !== undefined && typeof description !== "string") { - return `server "${name}": description must be a string`; - } - if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) { - return `server "${name}": timeout must be a positive number of seconds`; - } - 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")) { - if (!URL.canParse(value.url) || !/^https?:$/.test(new URL(value.url).protocol)) { - return `server "${name}": url must be an http or https URL`; - } - if (value.headers !== undefined && !isStringRecord(value.headers)) { - return `server "${name}": headers must map names to strings`; - } - const oauthError = validateOAuth(value.oauth); - if (oauthError) return `server "${name}": ${oauthError}`; - if (value.auth !== undefined) { - if (!isRecord(value.auth) || typeof value.auth.provider !== "string" || !value.auth.provider) { - return `server "${name}": auth.provider must be a provider name`; - } - const url = new URL(value.url); - if (url.protocol !== "https:" && !LOOPBACK_HOSTS.includes(url.hostname)) { - return `server "${name}": auth requires an https URL, or http on localhost, 127.0.0.1, or [::1]`; - } - } - return value as unknown as McpHttpServerConfig; - } - if (typeof value.command === "string" && (type === undefined || type === "stdio")) { - if ( - value.args !== undefined && - !(Array.isArray(value.args) && value.args.every((arg) => typeof arg === "string")) - ) { - return `server "${name}": args must be an array of strings`; - } - if (value.env !== undefined && !isStringRecord(value.env)) - return `server "${name}": env must map names to strings`; - if (value.cwd !== undefined && typeof value.cwd !== "string") return `server "${name}": cwd must be a string`; - return value as unknown as McpStdioServerConfig; - } - return `server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`; + const result = validateMcpServerFields(name, isRecord(raw) ? resolveExposureAliases(raw) : raw, { + checkMetadataDocumentCallback: (callbackUrl) => + isClientMetadataDocumentCallback(callbackUrl) + ? undefined + : 'oauth.clientRegistration "cimd" requires oauth.callbackUrl on localhost or 127.0.0.1 with path /callback', + }); + if (result.kind === "invalid") return result.error; + const error = + checkExposures(result.config) ?? + (result.kind === "http" ? checkAuth(result.config.auth, result.config.url) : undefined); + if (error) return `server "${name}": ${error}`; + // The cast holds because pi's fields were checked above, which the types cannot express. + return result.config as McpServerConfig; } /** A server an extension registered with `pi.registerMcpServer()`. */ diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index c926c4fbd..c638ee480 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -274,41 +274,3 @@ export function truncateLine( } return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; } - -export interface MiddleTruncationResult { - /** The start and end of the content with a `…N chars truncated…` marker between them. */ - content: string; - truncated: boolean; - /** Characters left out. */ - removedChars: number; - totalBytes: number; - totalLines: number; -} - -/** - * Keep the start and the end of `content`, half of `maxBytes` each, and replace the middle with a - * `…N chars truncated…` marker, like Codex does for tool output. Cuts only at character boundaries. - */ -export function truncateMiddle(content: string, maxBytes: number): MiddleTruncationResult { - const buf = Buffer.from(content, "utf-8"); - const totalLines = splitLinesForCounting(content).length; - if (buf.length <= maxBytes) { - return { content, truncated: false, removedChars: 0, totalBytes: buf.length, totalLines }; - } - // Continuation bytes (10xxxxxx) are not character starts. - const isBoundary = (index: number) => index >= buf.length || (buf[index] & 0xc0) !== 0x80; - let headEnd = Math.floor(maxBytes / 2); - while (headEnd > 0 && !isBoundary(headEnd)) headEnd--; - let tailStart = buf.length - (maxBytes - Math.floor(maxBytes / 2)); - while (tailStart < buf.length && !isBoundary(tailStart)) tailStart++; - const head = buf.subarray(0, headEnd).toString("utf-8"); - const tail = buf.subarray(tailStart).toString("utf-8"); - const removedChars = Array.from(buf.subarray(headEnd, tailStart).toString("utf-8")).length; - return { - content: `${head}…${removedChars} chars truncated…${tail}`, - truncated: true, - removedChars, - totalBytes: buf.length, - totalLines, - }; -} diff --git a/packages/coding-agent/src/extensions/mcp/config.ts b/packages/coding-agent/src/extensions/mcp/config.ts index d06fddfd8..8e8553c23 100644 --- a/packages/coding-agent/src/extensions/mcp/config.ts +++ b/packages/coding-agent/src/extensions/mcp/config.ts @@ -31,13 +31,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { isMcpServerOverride, mcpNamespace } from "@earendil-works/pi-mcp"; import { CONFIG_DIR_NAME } from "../../config.ts"; -import { - type McpExposure, - type McpServerConfig, - mcpNamespace, - validateMcpServerConfig, -} from "../../core/mcp-servers.ts"; +import { type McpExposure, type McpServerConfig, validateMcpServerConfig } from "../../core/mcp-servers.ts"; import { stripBom } from "../../utils/text.ts"; export type { @@ -74,11 +70,6 @@ export interface LoadedMcpConfig { const OVERRIDE_KEYS = ["enabled", "exposure", "toolExposure"]; -/** Whether an entry overrides a server defined elsewhere instead of defining one. */ -function isOverride(value: Record): boolean { - return value.command === undefined && value.url === undefined && value.type === undefined; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -106,7 +97,7 @@ function readConfigFile(path: string, scope: "global" | "project", state: McpCon if (typeof parsed.autoEnableCodemode === "boolean") state.autoEnableCodemode = parsed.autoEnableCodemode; else if (parsed.autoEnableCodemode !== undefined) errors.push(`${path}: autoEnableCodemode must be a boolean`); for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { - if (scope === "project" && isRecord(value) && isOverride(value)) { + if (scope === "project" && isRecord(value) && isMcpServerOverride(value)) { const base = servers.get(name); const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key)); if (!base) { @@ -180,7 +171,7 @@ export function updateMcpServerConfig( parsed.mcpServers = { ...servers, [name]: server }; } if (!isRecord(server)) throw new Error(`${path} does not define MCP server "${name}"`); - const keepDefaults = isOverride(server); + const keepDefaults = isMcpServerOverride(server); if (patch.enabled !== undefined) { if (patch.enabled && !keepDefaults) delete server.enabled; else server.enabled = patch.enabled; diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts index 8a97d7fb2..f2711704a 100644 --- a/packages/coding-agent/src/extensions/mcp/index.ts +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -27,6 +27,7 @@ */ import { join, resolve } from "node:path"; +import { createMcpToolName, mcpNamespace } from "@earendil-works/pi-mcp"; import type { SelectItem } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; import { getAgentDir } from "../../config.ts"; @@ -37,7 +38,6 @@ import type { ExtensionFactory, ToolDefinition, } from "../../core/extensions/types.ts"; -import { mcpNamespace } from "../../core/mcp-servers.ts"; import type { ModelRegistry } from "../../core/model-registry.ts"; import { openBrowser } from "../../utils/open-browser.ts"; import { CODEMODE_TOOL_NAME } from "../codemode/tool.ts"; @@ -61,7 +61,7 @@ import { import { loadMcpRuntime } from "./runtime.lazy.ts"; import type * as McpRuntime from "./runtime.ts"; import type { McpServerConnection, McpServerLog, McpTransportFactory } from "./runtime.ts"; -import { createMcpToolDefinition, createMcpToolName, createMcpToolRenderers, type McpToolDetails } from "./tools.ts"; +import { createMcpToolDefinition, createMcpToolRenderers, type McpToolDetails } from "./tools.ts"; import { type McpMenu, type McpUi, showMcpManager } from "./ui.ts"; export type { McpTransportFactory } from "./runtime.ts"; diff --git a/packages/coding-agent/src/extensions/mcp/oauth.ts b/packages/coding-agent/src/extensions/mcp/oauth.ts index 724ef38e5..b38865282 100644 --- a/packages/coding-agent/src/extensions/mcp/oauth.ts +++ b/packages/coding-agent/src/extensions/mcp/oauth.ts @@ -14,6 +14,7 @@ import { createHash } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { oauthErrorHtml, oauthSuccessHtml } from "@earendil-works/pi-ai/utils/oauth-page"; +import { mcpNamespace } from "@earendil-works/pi-mcp"; import { completeMcpSignIn, createMcpAuthProvider as createAuthProvider, @@ -31,7 +32,7 @@ import { import lockfile from "proper-lockfile"; import { APP_NAME, getAgentDir } from "../../config.ts"; import { type AuthStorageBackend, FileAuthStorageBackend } from "../../core/auth-storage.ts"; -import { isClientMetadataDocumentCallback, mcpNamespace } from "../../core/mcp-servers.ts"; +import { isClientMetadataDocumentCallback } from "../../core/mcp-servers.ts"; /** A refresh lock that its holder stopped renewing (the process was killed) is taken over after this. */ const REFRESH_LOCK_STALE_MS = 20_000; diff --git a/packages/coding-agent/src/extensions/mcp/resources.ts b/packages/coding-agent/src/extensions/mcp/resources.ts index c93ae4c80..5d12c39e9 100644 --- a/packages/coding-agent/src/extensions/mcp/resources.ts +++ b/packages/coding-agent/src/extensions/mcp/resources.ts @@ -11,14 +11,15 @@ */ import type { ImageContent, JsonValue, TextContent } from "@earendil-works/pi-ai"; -import type { - ContentBlock, - ListResourcesResult, - ListResourceTemplatesResult, - McpRequestOptions, - ReadResourceResult, - Resource, - ResourceTemplate, +import { + type ContentBlock, + type ListResourcesResult, + type ListResourceTemplatesResult, + limitMcpContent, + type McpRequestOptions, + type ReadResourceResult, + type Resource, + type ResourceTemplate, } from "@earendil-works/pi-mcp"; import type { TSchema } from "typebox"; import type { ToolAnnotations, ToolDefinition } from "../../core/extensions/types.ts"; @@ -28,7 +29,7 @@ import { READ_MCP_RESOURCE_TOOL, } from "../../core/mcp-servers.ts"; import type { McpExposure } from "./config.ts"; -import { limitMcpContent, type McpToolDetails, toModelContent, toToolExposure } from "./tools.ts"; +import { type McpToolDetails, saveToTempFile, toModelContent, toToolExposure } from "./tools.ts"; export { LIST_MCP_RESOURCE_TEMPLATES_TOOL, LIST_MCP_RESOURCES_TOOL, READ_MCP_RESOURCE_TOOL }; @@ -180,7 +181,10 @@ async function jsonResult( server: string | undefined, payload: Record, ): Promise<{ content: (TextContent | ImageContent)[]; details: McpToolDetails; structuredContent: JsonValue }> { - const { content, fullOutputPath } = await limitMcpContent([{ type: "text", text: JSON.stringify(payload) }]); + const { content, fullOutputPath } = await limitMcpContent( + [{ type: "text", text: JSON.stringify(payload) }], + saveToTempFile, + ); return { content, details: { server: server ?? "", tool, ...(fullOutputPath ? { fullOutputPath } : {}) }, @@ -321,6 +325,7 @@ export function createMcpResourceToolDefinitions(options: { const converted = await toModelContent(server.name, blocks); const { content, fullOutputPath } = await limitMcpContent( converted.length > 0 ? converted : [{ type: "text", text: `Resource ${uri} is empty.` }], + saveToTempFile, ); const contents = result.contents.map(({ _meta: _ignored, ...rest }) => rest); return { diff --git a/packages/coding-agent/src/extensions/mcp/tools.ts b/packages/coding-agent/src/extensions/mcp/tools.ts index 63f95dbb8..a484d21b2 100644 --- a/packages/coding-agent/src/extensions/mcp/tools.ts +++ b/packages/coding-agent/src/extensions/mcp/tools.ts @@ -10,14 +10,16 @@ * errors (`isError`) are error results for the model, but scripts still resolve to the result. */ -import { createHash } from "node:crypto"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { ImageContent, JsonValue, TextContent } from "@earendil-works/pi-ai"; import { type CallToolResult, type ContentBlock, + limitMcpContent, + type McpOutputSaver, type McpRequestOptions, type Tool as McpTool, + normalizeMcpInputSchema, toLlmContent, } from "@earendil-works/pi-mcp"; import { Container, Spacer, Text } from "@earendil-works/pi-tui"; @@ -31,7 +33,7 @@ import type { } from "../../core/extensions/types.ts"; import { READ_MCP_RESOURCE_TOOL } from "../../core/mcp-servers.ts"; import { formatToolCallWithArgs, getTextOutput, replaceTabs } from "../../core/tools/render-utils.ts"; -import { formatSize, truncateMiddle } from "../../core/tools/truncate.ts"; +import { formatSize } from "../../core/tools/truncate.ts"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; import { VisualLinePreview } from "../../modes/interactive/components/visual-truncate.ts"; import { writeOutputFile } from "../../utils/output-files.ts"; @@ -45,10 +47,6 @@ export function toToolExposure(exposure: McpExposure): ToolExposure { return exposure === "codemode" ? "deferred" : exposure; } -/** 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. */ -export const MCP_OUTPUT_MAX_BYTES = 20 * 1024; /** Visual (wrapped) result lines shown before the output is expanded. */ const OUTPUT_PREVIEW_LINES = 5; /** Tool that reads the resources named by resource links. */ @@ -61,12 +59,7 @@ export interface McpToolDetails { fullOutputPath?: string; } -/** - * Saves the full text of a truncated result, or a binary resource, and returns the file path. - * `extension` includes the dot, for example `.txt`. - */ -export type McpOutputSaver = (data: string | Uint8Array, extension: string) => Promise; - +/** Saves the full text of a truncated result, or a binary resource, to a temp file and returns its path. */ export function saveToTempFile(data: string | Uint8Array, extension: string): Promise { return writeOutputFile("pi-mcp", extension, data); } @@ -75,23 +68,6 @@ export interface McpToolCaller { callTool(name: string, args: Record, options: McpRequestOptions): Promise; } -/** - * `mcp____`, sanitized and shortened with a hash suffix when too long. Like Codex, - * everything but `[A-Za-z0-9_]` becomes `_`, so the name is also the identifier codemode scripts - * call it by. `isTaken` reports names used by a different MCP tool: sanitizing can map two tools to - * one name (`a-b` and `a_b`), which then get the hash suffix. - */ -export function createMcpToolName( - server: string, - tool: string, - isTaken: (name: string) => boolean = () => false, -): string { - const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_"); - if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name; - const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8); - return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`; -} - function textOf(content: readonly (TextContent | ImageContent)[]): string { return content .filter((block): block is TextContent => block.type === "text") @@ -173,33 +149,6 @@ export function createMcpResultSchema(structuredContentSchema: Record { - const combined = textOf(content); - const truncation = truncateMiddle(combined, MCP_OUTPUT_MAX_BYTES); - if (!truncation.truncated) return { content }; - let fullOutputPath: string | undefined; - let where: string; - try { - fullOutputPath = await saveOutput(combined, ".txt"); - where = `[Full output: ${fullOutputPath} (read it with offset/limit)]`; - } catch (error) { - where = `[Could not save the full output: ${error instanceof Error ? error.message : String(error)}]`; - } - const tokens = Math.ceil(truncation.totalBytes / 4); - const text = `Warning: truncated output (original token count: ${tokens})\nTotal output lines: ${truncation.totalLines}\n\n${truncation.content}\n\n${where}`; - return { - content: [{ type: "text", text }, ...content.filter((block) => block.type === "image")], - ...(fullOutputPath ? { fullOutputPath } : {}), - }; -} - export interface ConvertMcpResultOptions { /** Saves truncated text and binary resources. Default: a temp file. */ saveOutput?: McpOutputSaver; @@ -275,7 +224,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 ?? saveToTempFile); const { _meta: _ignored, ...scriptResult } = result; return { content, @@ -285,18 +234,6 @@ export async function convertMcpResult( }; } -/** - * Tool input schemas must be objects. MCP servers may omit `type`, and some providers reject object - * schemas without `properties`. - */ -function toParameters(schema: Record): TSchema { - return { - ...schema, - type: schema.type ?? "object", - ...(schema.properties === undefined ? { properties: {} } : {}), - } as unknown as TSchema; -} - const ANNOTATION_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const; /** The boolean hints of an MCP tool's annotations, or undefined when it has none. */ @@ -328,7 +265,8 @@ export function createMcpToolDefinition(options: { name: options.name, label, description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, - parameters: toParameters(tool.inputSchema), + // An object schema, which is what TypeBox schemas are at runtime. + parameters: normalizeMcpInputSchema(tool.inputSchema) as unknown as TSchema, outputSchema: createMcpResultSchema(tool.outputSchema), exposure: toToolExposure(options.exposure), namespace: options.namespace, diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index 7bc18ca43..1b22e32fd 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { renderToolOutputType } from "@earendil-works/pi-codemode/declarations"; import { + createMcpToolName, type JsonRpcMessage, LATEST_PROTOCOL_VERSION, McpAuthRequiredError, @@ -14,7 +15,6 @@ import { import { createInMemoryTransportPair, type InMemoryTransport } from "@earendil-works/pi-mcp/testing"; import { afterEach, describe, expect, it } from "vitest"; import { InMemoryAuthStorageBackend } from "../src/core/auth-storage.ts"; -import { truncateMiddle } from "../src/core/tools/truncate.ts"; import { addMcpServerConfig, getMcpToolExposure, @@ -29,7 +29,7 @@ import { McpServerConnection, McpServerLog, } from "../src/extensions/mcp/runtime.ts"; -import { convertMcpResult, createMcpResultSchema, createMcpToolName } from "../src/extensions/mcp/tools.ts"; +import { convertMcpResult, createMcpResultSchema } 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 @@ -448,18 +448,6 @@ describe("MCP tools", () => { await convertMcpResult("docs", "small", { content: [{ type: "text", text: "ok" }] }, { saveOutput }); expect(saved).toHaveLength(1); }); - - it("cuts multi-byte text only at character boundaries", () => { - const text = `${"é".repeat(20_000)}end`; - const result = truncateMiddle(text, 1001); - expect(result.truncated).toBe(true); - expect(result.content).not.toContain("\uFFFD"); - expect(result.content.endsWith("end")).toBe(true); - const [head, tail] = result.content.split(/…\d+ chars truncated…/); - expect(Buffer.byteLength(head)).toBeLessThanOrEqual(500); - expect(Buffer.byteLength(tail)).toBeLessThanOrEqual(501); - expect(Array.from(head).length + Array.from(tail).length + result.removedChars).toBe(Array.from(text).length); - }); }); describe("MCP connections", () => { diff --git a/packages/coding-agent/test/suite/agent-session-mcp.test.ts b/packages/coding-agent/test/suite/agent-session-mcp.test.ts index 25af7f9bb..ca3df6bcf 100644 --- a/packages/coding-agent/test/suite/agent-session-mcp.test.ts +++ b/packages/coding-agent/test/suite/agent-session-mcp.test.ts @@ -1,7 +1,7 @@ import { readFileSync, rmSync } from "node:fs"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import type { SystemMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat"; -import { type JsonRpcRequest, LATEST_PROTOCOL_VERSION } from "@earendil-works/pi-mcp"; +import { createMcpToolName, type JsonRpcRequest, LATEST_PROTOCOL_VERSION } from "@earendil-works/pi-mcp"; import { createInMemoryTransportPair } from "@earendil-works/pi-mcp/testing"; import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -10,7 +10,6 @@ import type { SessionManager } from "../../src/core/session-manager.ts"; import { createCodemodeExtension } from "../../src/extensions/codemode/index.ts"; import type { McpExposure, McpServerEntry } from "../../src/extensions/mcp/config.ts"; import { createMcpExtension, MCP_SERVERS_SECTION } from "../../src/extensions/mcp/index.ts"; -import { createMcpToolName } from "../../src/extensions/mcp/tools.ts"; import { createToolSearchExtension } from "../../src/extensions/tool-search/index.ts"; import { TOOL_SEARCH_DESCRIPTION } from "../../src/extensions/tool-search/tool.ts"; import { createTestExtensionsResult, createTestResourceLoader } from "../utilities.ts"; diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 966cc0d07..ece49661a 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -6,6 +6,7 @@ - Added `PipeTransport`, MCP's stdio transport over a program started by the caller through an `McpPipe` (`write`, `closeStdin`, `close`, `exited`), for example `@earendil-works/pi-env`'s `openProcess()` on another machine. `StdioTransport` and `PipeTransport` share the newline-delimited framing and the stderr tail. - Added MCP OAuth sign-in in two halves (`@earendil-works/pi-mcp/oauth`), moved from pi's coding agent: `startMcpSignIn()` refreshes the stored tokens or returns the authorization URL, and `completeMcpSignIn()` exchanges the code later, possibly in another request with the same state store. The code comes from a loopback callback server (`listenForLoopbackSignIn()`, `waitForLoopbackSignIn()`), the application's own callback route, or a pasted redirect URL (`responseFromRedirectUrl()`). Client ID Metadata Documents (`clientMetadataDocuments`) and the client name are the caller's. Also added `createMcpAuthProvider()`, which refreshes tokens once across requests and, through `McpOAuthServerStore.withRefreshLock()`, across processes, and the `refreshOnly` option of `authorizeMcp()`, which refreshes without registering a client or replacing a pending authorization's `state` and PKCE verifier. +- Added helpers that let applications name and configure MCP tools like pi's coding agent, moved from it: `createMcpToolName()` (`mcp____`, sanitized, with a hash suffix for long or colliding names) and `mcpNamespace()`; `normalizeMcpInputSchema()`, which makes an input schema an object schema; `limitMcpContent()` and `MCP_OUTPUT_MAX_BYTES`, which cut model-facing text over 20KB in the middle and optionally save the full text through a caller's `McpOutputSaver`; and `validateMcpServerConfig()`, which validates one entry of the `mcpServers` configuration shape (stdio, Streamable HTTP, `oauth`, `enabled`, `description`, `timeout`), returns the entry tagged with its transport, and keeps the application's own fields for it to check afterwards. `isMcpServerOverride()` recognizes entries that only override a server defined elsewhere. ## [1.5.0] - 2026-11-02 diff --git a/packages/mcp/README.md b/packages/mcp/README.md index dc56ba8b3..d9e7f6f12 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -27,6 +27,8 @@ await client.close(); For a remote server, use `new StreamableHttpTransport({ url, headers })`. Fetch can be injected for proxying or custom networking. +An MCP transport owns framing and I/O. It delivers individual JSON-RPC messages to `McpClient`; the client owns request correlation, initialization, timeouts, cancellation, server requests, and protocol-level helpers. + ### Stdio servers started elsewhere `PipeTransport` speaks MCP's stdio framing over a program that something else starts, for example on another machine. `open` starts the program, connects its stdout and stderr to the given handlers, and returns an `McpPipe` (`write`, `closeStdin`, `close` to kill, and an `exited` promise). `@earendil-works/pi-env`'s `openProcess()` returns one, without an adapter: @@ -68,6 +70,8 @@ const tools: AgentTool[] = (await client.listTools()).map((tool) => ({ })); ``` +For applications that name tools like pi's coding agent: `createMcpToolName(server, tool, isTaken)` builds `mcp____`, limited to the characters and the 64-character length providers accept, with a hash suffix for long names and for names `isTaken` reports as used by another tool. `mcpNamespace(server)` is the prefix of a server's tool names. `normalizeMcpInputSchema()` turns an input schema into an object schema with `properties`, and `limitMcpContent(content, saveOutput)` keeps model-facing text within `MCP_OUTPUT_MAX_BYTES` (20KB) by cutting its middle; `saveOutput`, when given, stores the full text and returns the path the model can read it from. + The [mcp-codemode example](https://github.com/earendil-works/pi/tree/main/packages/agent/examples/mcp-codemode) also forwards progress, passes `structuredContent` through, and lets `@earendil-works/pi-codemode` scripts call the tools. ### OAuth @@ -145,7 +149,23 @@ For a browser on the same machine, `listenForLoopbackSignIn()` starts a loopback The OAuth implementation is adapted from the MIT-licensed Model Context Protocol TypeScript SDK v1.29.0. Its license is included under `LICENSES/`. -An MCP transport owns framing and I/O. It delivers individual JSON-RPC messages to `McpClient`; the client owns request correlation, initialization, timeouts, cancellation, server requests, and protocol-level helpers. +### Configuration + +`validateMcpServerConfig(name, entry, options)` validates one entry of the `mcpServers` shape that MCP clients share: stdio servers (`command`, `args`, `env`, `cwd`), Streamable HTTP servers (`url`, `headers`, `oauth`), and `enabled`, `description`, and `timeout`. It returns `{ kind: "stdio" | "http", config }` or `{ kind: "invalid", error }`. Fields it does not know stay in `config`, typed `unknown`, for the application to check afterwards, depending on `kind` where it matters. Resolving `${VAR}` references and relative paths is left to the application. `oauth.clientRegistration: "cimd"` is accepted only with `options.checkMetadataDocumentCallback`, which checks that the application's Client ID Metadata Documents list the callback. + +```typescript +import { validateMcpServerConfig } from "@earendil-works/pi-mcp"; + +for (const [name, entry] of Object.entries(file.mcpServers)) { + const result = validateMcpServerConfig(name, entry); + if (result.kind === "invalid") errors.push(result.error); + else if (result.config.shared !== undefined && typeof result.config.shared !== "boolean") { + errors.push(`server "${name}": shared must be a boolean`); + } else servers.set(name, result); +} +``` + +`isMcpServerOverride(entry)` is true for entries without `command`, `url`, or `type`, which applications treat as overriding a few fields of a server defined in another file. ## Supported protocol surface diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts new file mode 100644 index 000000000..6b7924503 --- /dev/null +++ b/packages/mcp/src/config.ts @@ -0,0 +1,243 @@ +/** + * The `mcpServers` configuration shape that MCP clients share, so configurations can be copied between + * them: stdio servers (`command`, `args`, `env`, `cwd`), Streamable HTTP servers (`url`, `headers`, + * `oauth`), and `enabled`, `description` and `timeout` for both. + * + * ```json + * { + * "mcpServers": { + * "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, + * "docs": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" } } + * } + * } + * ``` + * + * Applications add fields of their own: {@link validateMcpServerConfig} keeps fields it does not know + * for the caller to check afterwards. + */ + +/** Fields every server entry may have. */ +interface McpServerConfigBase { + /** What the server offers, in a sentence. */ + description?: string; + /** Set to false to keep the entry without connecting. Default: true. */ + enabled?: boolean; + /** Per-request timeout in seconds. */ + timeout?: number; +} + +/** A server started as a local program that speaks MCP over stdin and stdout. */ +export interface McpStdioServerConfig extends McpServerConfigBase { + type?: "stdio"; + command: string; + args?: string[]; + /** Added to the environment. The application decides whether values may reference variables (`${NAME}`). */ + env?: Record; + /** The application decides what relative paths resolve against. */ + cwd?: string; +} + +/** OAuth client settings for servers that do not support dynamic client registration. */ +export interface McpOAuthConfig { + /** Pre-registered client id. Without it, the application registers a client with the authorization server. */ + clientId?: string; + /** As configured; the application decides whether it may reference variables. */ + clientSecret?: string; + /** + * Port of the loopback callback server, for clients registered with a fixed redirect URI. Without + * `callbackUrl`, the redirect URI is `http://127.0.0.1:/callback`. + */ + callbackPort?: number; + /** + * Redirect URI registered for `clientId`, for example `http://localhost:8080/oauth/callback`. It must + * be an `http` URI on `localhost`, `127.0.0.1`, or `[::1]`. Without a port, the callback server + * listens on `callbackPort` or a free port, which is added to the URI (RFC 8252). + */ + callbackUrl?: string; + /** Scopes to request, separated by spaces. Default: the scopes the server advertises. */ + scope?: string; + /** `client_name` sent with dynamic client registration. Default: the application's name. */ + clientName?: string; + /** + * How the application identifies itself without `clientId`. `auto` (default): its Client ID Metadata + * Document when the authorization server supports them for public clients, otherwise dynamic client + * registration. `cimd`: always the document, and sign-in fails without support. `dcr`: always + * dynamic client registration. + */ + clientRegistration?: "auto" | "dcr" | "cimd"; + /** + * Authorization server metadata document (RFC 8414 or OpenID Connect discovery) to use instead of + * discovery through the server, for servers that advertise a wrong authorization server or none. + * The document is trusted as configured. Must use https, except on loopback hosts. + */ + authServerMetadataUrl?: string; +} + +/** A server reached over Streamable HTTP. */ +export interface McpHttpServerConfig extends McpServerConfigBase { + type?: "http"; + url: string; + /** Sent with every request. The application decides whether values may reference variables. */ + headers?: Record; + oauth?: McpOAuthConfig; +} + +/** One entry of the `mcpServers` shape: a stdio or a Streamable HTTP server. */ +export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig; + +/** + * A validated server entry, tagged with its transport, or why it is invalid. The config is the entry + * as given: fields the validator does not know are kept, unchecked, for the caller to check. + */ +export type McpServerConfigResult = + | { kind: "stdio"; config: McpStdioServerConfig & Record } + | { kind: "http"; config: McpHttpServerConfig & Record } + | { kind: "invalid"; error: string }; + +/** What the application adds to the validation of server entries. */ +export interface McpServerConfigOptions { + /** + * Checks the `oauth.callbackUrl` (undefined: none configured) of an entry with + * `oauth.clientRegistration: "cimd"`: the application's Client ID Metadata Documents must list it. + * Returns an error message. Without this check, `"cimd"` is refused. + */ + checkMetadataDocumentCallback?(callbackUrl: string | undefined): string | undefined; +} + +const SERVER_NAME = /^[A-Za-z0-9_-]+$/; + +/** + * Validate one entry of the `mcpServers` shape (configuration files are a trust boundary). Server + * names are letters, digits, `_` and `-`, which tool names and namespaces are built from. + */ +export function validateMcpServerConfig( + name: string, + raw: unknown, + options: McpServerConfigOptions = {}, +): McpServerConfigResult { + const invalid = (error: string): McpServerConfigResult => ({ kind: "invalid", error }); + if (!SERVER_NAME.test(name)) return invalid(`invalid server name "${name}" (use letters, digits, "_" and "-")`); + if (!isRecord(raw)) return invalid(`server "${name}" must be an object`); + const fieldsError = validateBaseFields(raw); + if (fieldsError) return invalid(`server "${name}": ${fieldsError}`); + const { type, url, command } = raw; + if (type === "sse") { + return invalid(`server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`); + } + // The casts below hold because the fields were checked one by one, which the types cannot express. + if (typeof url === "string" && (type === undefined || type === "http" || type === "streamable-http")) { + const error = validateHttpFields(raw, url, options); + if (error) return invalid(`server "${name}": ${error}`); + return { kind: "http", config: raw as McpHttpServerConfig & Record }; + } + if (typeof command === "string" && (type === undefined || type === "stdio")) { + const error = validateStdioFields(raw); + if (error) return invalid(`server "${name}": ${error}`); + return { kind: "stdio", config: raw as McpStdioServerConfig & Record }; + } + return invalid(`server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateBaseFields(entry: Record): string | undefined { + const { enabled, timeout, description } = entry; + if (enabled !== undefined && typeof enabled !== "boolean") return "enabled must be a boolean"; + if (description !== undefined && typeof description !== "string") return "description must be a string"; + if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) { + return "timeout must be a positive number of seconds"; + } + return undefined; +} + +function validateHttpFields( + entry: Record, + url: string, + options: McpServerConfigOptions, +): string | undefined { + if (!URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) return "url must be an http or https URL"; + if (entry.headers !== undefined && !isStringRecord(entry.headers)) return "headers must map names to strings"; + return validateOAuth(entry.oauth, options); +} + +function validateStdioFields(entry: Record): string | undefined { + const { args, env, cwd } = entry; + if (args !== undefined && !(Array.isArray(args) && args.every((arg) => typeof arg === "string"))) { + return "args must be an array of strings"; + } + if (env !== undefined && !isStringRecord(env)) return "env must map names to strings"; + if (cwd !== undefined && typeof cwd !== "string") return "cwd must be a string"; + return undefined; +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); +} + +const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; + +function validateOAuth(value: unknown, options: McpServerConfigOptions): string | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) return "oauth must be an object"; + if (value.clientId !== undefined && typeof value.clientId !== "string") return "oauth.clientId must be a string"; + if (value.clientSecret !== undefined && typeof value.clientSecret !== "string") { + return "oauth.clientSecret must be a string"; + } + const port = value.callbackPort; + if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { + return "oauth.callbackPort must be a port number"; + } + if (value.callbackUrl !== undefined) { + if (typeof value.callbackUrl !== "string" || !isLoopbackRedirectUri(value.callbackUrl)) { + return "oauth.callbackUrl must be an http URI on localhost, 127.0.0.1, or [::1] without query or fragment"; + } + const urlPort = new URL(value.callbackUrl).port; + if (urlPort && port !== undefined && Number(urlPort) !== port) { + return "oauth.callbackUrl and oauth.callbackPort name different ports"; + } + } + if (value.scope !== undefined && typeof value.scope !== "string") return "oauth.scope must be a string"; + if (value.clientName !== undefined && (typeof value.clientName !== "string" || !value.clientName.trim())) { + return "oauth.clientName must be a non-empty string"; + } + const registration = value.clientRegistration; + if (registration !== undefined && registration !== "auto" && registration !== "dcr") { + if (registration !== "cimd") return 'oauth.clientRegistration must be "auto", "dcr", or "cimd"'; + if (value.clientId !== undefined || value.clientName !== undefined) { + return 'oauth.clientRegistration "cimd" cannot be combined with oauth.clientId or oauth.clientName'; + } + if (!options.checkMetadataDocumentCallback) { + return 'oauth.clientRegistration "cimd" is not supported: there are no Client ID Metadata Documents'; + } + const callbackError = options.checkMetadataDocumentCallback( + typeof value.callbackUrl === "string" ? value.callbackUrl : undefined, + ); + if (callbackError) return callbackError; + } + const metadataUrl = value.authServerMetadataUrl; + if (metadataUrl !== undefined) { + const url = typeof metadataUrl === "string" && URL.canParse(metadataUrl) ? new URL(metadataUrl) : undefined; + if (!url || !(url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)))) { + return "oauth.authServerMetadataUrl must be an https URL, or http on localhost, 127.0.0.1, or [::1]"; + } + } + return undefined; +} + +/** Whether a redirect URI can be served by a loopback callback server. */ +function isLoopbackRedirectUri(value: string): boolean { + if (!URL.canParse(value)) return false; + const url = new URL(value); + return url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === ""; +} + +/** + * Whether an entry overrides a server defined elsewhere instead of defining one: it has no `command`, + * `url` or `type`. Applications let a less trusted file (a project's) override a few fields, such as + * `enabled`, of a server a trusted file defines; which fields is theirs to decide. + */ +export function isMcpServerOverride(entry: Record): boolean { + return entry.command === undefined && entry.url === undefined && entry.type === undefined; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e11132e8a..381f7f1b7 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,5 +1,23 @@ export type { AuthProvider, McpFetch, UnauthorizedContext } from "./auth-provider.ts"; export { McpClient, type McpClientOptions, type McpRequestOptions } from "./client.ts"; +export { + isMcpServerOverride, + type McpHttpServerConfig, + type McpOAuthConfig, + type McpServerConfig, + type McpServerConfigOptions, + type McpServerConfigResult, + type McpStdioServerConfig, + validateMcpServerConfig, +} from "./config.ts"; +export { normalizeMcpInputSchema } from "./input-schema.ts"; +export { createMcpToolName, mcpNamespace } from "./naming.ts"; +export { + type LimitedMcpContent, + limitMcpContent, + MCP_OUTPUT_MAX_BYTES, + type McpOutputSaver, +} from "./output-limit.ts"; export { type AudioContent, type BlobResourceContents, diff --git a/packages/mcp/src/input-schema.ts b/packages/mcp/src/input-schema.ts new file mode 100644 index 000000000..9da17b3fb --- /dev/null +++ b/packages/mcp/src/input-schema.ts @@ -0,0 +1,13 @@ +/** An MCP tool's input schema in the shape LLM tool APIs accept. */ + +/** + * The tool's input schema as an object schema. Tool parameters must be objects, MCP servers may omit + * `type`, and some providers reject object schemas without `properties`. Other keywords are kept. + */ +export function normalizeMcpInputSchema(schema: Record): Record { + return { + ...schema, + type: schema.type ?? "object", + ...(schema.properties === undefined ? { properties: {} } : {}), + }; +} diff --git a/packages/mcp/src/naming.ts b/packages/mcp/src/naming.ts new file mode 100644 index 000000000..a7b69fc34 --- /dev/null +++ b/packages/mcp/src/naming.ts @@ -0,0 +1,35 @@ +/** + * Names of MCP tools and of their servers' namespaces as models and code mode scripts see them. + * Applications that name tools the same way rank and group them the same way in tool search and + * code mode. + */ + +import { createHash } from "node:crypto"; + +/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */ +const MAX_TOOL_NAME_LENGTH = 64; + +/** + * `mcp____`, sanitized and shortened with a hash suffix when too long. Like Codex, + * everything but `[A-Za-z0-9_]` becomes `_`, so the name is also the identifier code mode scripts + * call it by. `isTaken` reports names used by a different MCP tool: sanitizing can map two tools to + * one name (`a-b` and `a_b`), which then get the hash suffix. + */ +export function createMcpToolName( + server: string, + tool: string, + isTaken: (name: string) => boolean = () => false, +): string { + const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_"); + if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name; + const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8); + return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`; +} + +/** + * Namespace of a server's tools: `mcp__` with `-` replaced by `_`, like the tool names. Server + * names that differ only in `-` and `_` share a namespace, so a configuration must not have both. + */ +export function mcpNamespace(server: string): string { + return `mcp__${server.replace(/-/g, "_")}`; +} diff --git a/packages/mcp/src/output-limit.ts b/packages/mcp/src/output-limit.ts new file mode 100644 index 000000000..304f96994 --- /dev/null +++ b/packages/mcp/src/output-limit.ts @@ -0,0 +1,77 @@ +/** + * The size limit of the model-facing text of MCP results. Longer text keeps its start and end with the + * middle cut out, like Codex does, and the caller may save the full text where the model can read it. + */ + +import type { LlmContent } from "./protocol/content.ts"; + +/** Model-facing text of an MCP result beyond this is cut in the middle. */ +export const MCP_OUTPUT_MAX_BYTES = 20 * 1024; + +/** + * Saves data the model cannot get in full, such as the full text of a truncated result, and returns + * where it was saved (a path the model can read). `extension` includes the dot, for example `.txt`. + */ +export type McpOutputSaver = (data: string | Uint8Array, extension: string) => Promise; + +/** Model-facing content within the limit, and where the full text went when it was saved. */ +export interface LimitedMcpContent { + content: LlmContent[]; + fullOutputPath?: string; +} + +/** + * 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 when `saveOutput` + * is given; images follow it. Content within the limit is returned as it is. + */ +export async function limitMcpContent(content: LlmContent[], saveOutput?: McpOutputSaver): Promise { + const combined = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); + const truncation = truncateMiddle(combined, MCP_OUTPUT_MAX_BYTES); + if (!truncation) return { content }; + let fullOutputPath: string | undefined; + let where = ""; + if (saveOutput) { + try { + fullOutputPath = await saveOutput(combined, ".txt"); + where = `\n\n[Full output: ${fullOutputPath} (read it with offset/limit)]`; + } catch (error) { + where = `\n\n[Could not save the full output: ${error instanceof Error ? error.message : String(error)}]`; + } + } + const tokens = Math.ceil(truncation.totalBytes / 4); + const text = `Warning: truncated output (original token count: ${tokens})\nTotal output lines: ${truncation.totalLines}\n\n${truncation.content}${where}`; + return { + content: [{ type: "text", text }, ...content.filter((block) => block.type === "image")], + ...(fullOutputPath ? { fullOutputPath } : {}), + }; +} + +/** Text cut in the middle, with the size of the whole text. */ +interface MiddleTruncation { + /** The start and the end of the text with a `…N chars truncated…` marker between them. */ + content: string; + totalBytes: number; + totalLines: number; +} + +/** + * Keep the start and the end of `text`, half of `maxBytes` each, and replace the middle with a + * `…N chars truncated…` marker. Cuts only at character boundaries. Undefined when `text` fits. + */ +function truncateMiddle(text: string, maxBytes: number): MiddleTruncation | undefined { + const buf = Buffer.from(text, "utf-8"); + if (buf.length <= maxBytes) return undefined; + // Continuation bytes (10xxxxxx) are not character starts. + const isBoundary = (index: number) => index >= buf.length || (buf[index] & 0xc0) !== 0x80; + let headEnd = Math.floor(maxBytes / 2); + while (headEnd > 0 && !isBoundary(headEnd)) headEnd--; + let tailStart = buf.length - (maxBytes - Math.floor(maxBytes / 2)); + while (tailStart < buf.length && !isBoundary(tailStart)) tailStart++; + const head = buf.subarray(0, headEnd).toString("utf-8"); + const tail = buf.subarray(tailStart).toString("utf-8"); + const removedChars = Array.from(buf.subarray(headEnd, tailStart).toString("utf-8")).length; + // A final newline ends the last line instead of starting another. + const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0); + return { content: `${head}…${removedChars} chars truncated…${tail}`, totalBytes: buf.length, totalLines: lines }; +} diff --git a/packages/mcp/test/config.test.ts b/packages/mcp/test/config.test.ts new file mode 100644 index 000000000..157fea872 --- /dev/null +++ b/packages/mcp/test/config.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + isMcpServerOverride, + type McpServerConfigOptions, + type McpServerConfigResult, + validateMcpServerConfig, +} from "../src/index.ts"; + +/** The error of an invalid entry, or the empty string. */ +function errorOf(result: McpServerConfigResult): string { + return result.kind === "invalid" ? result.error : ""; +} + +describe("validateMcpServerConfig", () => { + it("accepts stdio and HTTP entries and keeps them as given", () => { + const stdio = { command: "npx", args: ["-y", "server"], env: { A: "1" }, cwd: "sub", enabled: false }; + expect(validateMcpServerConfig("files", stdio)).toEqual({ kind: "stdio", config: stdio }); + expect(validateMcpServerConfig("files", { type: "stdio", command: "x" }).kind).toBe("stdio"); + + const http = { + url: "https://example.com/mcp", + headers: { Authorization: "Bearer token" }, + oauth: { clientId: "id", callbackPort: 8080, scope: "read" }, + timeout: 30, + description: "Docs", + }; + const result = validateMcpServerConfig("docs", http); + expect(result).toEqual({ kind: "http", config: http }); + if (result.kind === "http") expect(result.config).toBe(http); + for (const type of ["http", "streamable-http"]) { + expect(validateMcpServerConfig("docs", { type, url: "http://localhost:3000/mcp" }).kind).toBe("http"); + } + }); + + it("picks the transport from type, url and command", () => { + // With both, url wins unless the type says stdio. + expect(validateMcpServerConfig("s", { url: "https://a.example", command: "x" }).kind).toBe("http"); + expect(validateMcpServerConfig("s", { type: "stdio", url: "https://a.example", command: "x" }).kind).toBe( + "stdio", + ); + expect(errorOf(validateMcpServerConfig("s", { type: "http", command: "x" }))).toBe( + 'server "s" needs either "command" (stdio) or "url" (streamable HTTP)', + ); + expect(errorOf(validateMcpServerConfig("s", { type: "sse", url: "https://a.example/sse" }))).toBe( + 'server "s": legacy SSE transport is not supported; use the streamable HTTP URL', + ); + }); + + it("reports invalid names and fields", () => { + const cases: [string, unknown, string][] = [ + ["bad name", { command: "x" }, 'invalid server name "bad name" (use letters, digits, "_" and "-")'], + ["s", "npx", 'server "s" must be an object'], + ["s", [], 'server "s" must be an object'], + ["s", { command: "x", enabled: "yes" }, 'server "s": enabled must be a boolean'], + ["s", { command: "x", description: 1 }, 'server "s": description must be a string'], + ["s", { command: "x", timeout: 0 }, 'server "s": timeout must be a positive number of seconds'], + ["s", { command: "x", args: "a b" }, 'server "s": args must be an array of strings'], + ["s", { command: "x", env: { A: 1 } }, 'server "s": env must map names to strings'], + ["s", { command: "x", cwd: 1 }, 'server "s": cwd must be a string'], + ["s", { url: "file:///x" }, 'server "s": url must be an http or https URL'], + ["s", { url: "not a url" }, 'server "s": url must be an http or https URL'], + ["s", { url: "https://a.example", headers: { A: 1 } }, 'server "s": headers must map names to strings'], + ["s", {}, 'server "s" needs either "command" (stdio) or "url" (streamable HTTP)'], + ]; + for (const [name, raw, error] of cases) expect(errorOf(validateMcpServerConfig(name, raw))).toBe(error); + }); + + it("checks oauth settings", () => { + const oauth = (settings: unknown) => + errorOf(validateMcpServerConfig("s", { url: "https://a.example", oauth: settings })); + expect(oauth("x")).toBe('server "s": oauth must be an object'); + expect(oauth({ clientId: 1 })).toBe('server "s": oauth.clientId must be a string'); + expect(oauth({ clientSecret: 1 })).toBe('server "s": oauth.clientSecret must be a string'); + expect(oauth({ callbackPort: 70000 })).toBe('server "s": oauth.callbackPort must be a port number'); + expect(oauth({ callbackUrl: "https://localhost/callback" })).toContain("oauth.callbackUrl must be an http URI"); + expect(oauth({ callbackUrl: "http://example.com/callback" })).toContain("oauth.callbackUrl must be an http URI"); + expect(oauth({ callbackUrl: "http://localhost/callback?x=1" })).toContain( + "oauth.callbackUrl must be an http URI", + ); + expect(oauth({ callbackUrl: "http://[::1]:8080/cb", callbackPort: 8080 })).toBe(""); + expect(oauth({ callbackUrl: "http://localhost:8080/cb", callbackPort: 9090 })).toBe( + 'server "s": oauth.callbackUrl and oauth.callbackPort name different ports', + ); + expect(oauth({ scope: 1 })).toBe('server "s": oauth.scope must be a string'); + expect(oauth({ clientName: " " })).toBe('server "s": oauth.clientName must be a non-empty string'); + expect(oauth({ clientRegistration: "x" })).toBe( + 'server "s": oauth.clientRegistration must be "auto", "dcr", or "cimd"', + ); + expect(oauth({ clientRegistration: "dcr", clientName: "app" })).toBe(""); + expect(oauth({ authServerMetadataUrl: "http://auth.example/.well-known/x" })).toContain( + "oauth.authServerMetadataUrl must be an https URL", + ); + expect(oauth({ authServerMetadataUrl: "http://127.0.0.1/.well-known/x" })).toBe(""); + }); + + it("allows cimd only with the application's check of the callback", () => { + const entry = (oauth: Record) => ({ url: "https://a.example", oauth }); + expect(errorOf(validateMcpServerConfig("s", entry({ clientRegistration: "cimd" })))).toBe( + 'server "s": oauth.clientRegistration "cimd" is not supported: there are no Client ID Metadata Documents', + ); + const checked: (string | undefined)[] = []; + const options: McpServerConfigOptions = { + checkMetadataDocumentCallback: (callbackUrl) => { + checked.push(callbackUrl); + return callbackUrl?.endsWith("/other") ? "the documents do not list it" : undefined; + }, + }; + expect(validateMcpServerConfig("s", entry({ clientRegistration: "cimd" }), options).kind).toBe("http"); + expect( + errorOf( + validateMcpServerConfig( + "s", + entry({ clientRegistration: "cimd", callbackUrl: "http://localhost/other" }), + options, + ), + ), + ).toBe('server "s": the documents do not list it'); + expect(checked).toEqual([undefined, "http://localhost/other"]); + expect( + errorOf(validateMcpServerConfig("s", entry({ clientRegistration: "cimd", clientId: "id" }), options)), + ).toBe('server "s": oauth.clientRegistration "cimd" cannot be combined with oauth.clientId or oauth.clientName'); + }); +}); + +describe("extending validateMcpServerConfig", () => { + /** An application's own fields, like p2's `shared` and `runOn`, checked on the result. */ + function validateAppConfig(name: string, raw: unknown): string { + const result = validateMcpServerConfig(name, raw); + if (result.kind === "invalid") return result.error; + const { shared, runOn } = result.config; + if (shared !== undefined && typeof shared !== "boolean") return `server "${name}": shared must be a boolean`; + if (result.kind === "http" && runOn !== undefined) return `server "${name}": runOn is for stdio`; + return `${result.kind} shared=${String(shared)}`; + } + + it("keeps unknown fields for the caller, after the shared fields are valid", () => { + expect(validateAppConfig("s", { command: "x", shared: true, runOn: "host" })).toBe("stdio shared=true"); + expect(validateAppConfig("s", { url: "https://a.example", runOn: "host" })).toBe( + 'server "s": runOn is for stdio', + ); + expect(validateAppConfig("s", { command: "x", shared: 1 })).toBe('server "s": shared must be a boolean'); + // The shared fields are reported first. + expect(validateAppConfig("s", { command: "x", shared: 1, enabled: "no" })).toBe( + 'server "s": enabled must be a boolean', + ); + }); +}); + +describe("isMcpServerOverride", () => { + it("is true for entries without command, url or type", () => { + expect(isMcpServerOverride({ enabled: false })).toBe(true); + expect(isMcpServerOverride({})).toBe(true); + expect(isMcpServerOverride({ command: "x" })).toBe(false); + expect(isMcpServerOverride({ url: "https://a.example" })).toBe(false); + expect(isMcpServerOverride({ type: "stdio", enabled: false })).toBe(false); + }); +}); diff --git a/packages/mcp/test/input-schema.test.ts b/packages/mcp/test/input-schema.test.ts new file mode 100644 index 000000000..f68d2c6af --- /dev/null +++ b/packages/mcp/test/input-schema.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMcpInputSchema } from "../src/index.ts"; + +describe("normalizeMcpInputSchema", () => { + it("adds type object and empty properties when missing", () => { + expect(normalizeMcpInputSchema({})).toEqual({ type: "object", properties: {} }); + expect(normalizeMcpInputSchema({ type: "object" })).toEqual({ type: "object", properties: {} }); + }); + + it("keeps the schema's own keywords", () => { + const schema = { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }; + expect(normalizeMcpInputSchema(schema)).toEqual(schema); + expect(normalizeMcpInputSchema({ properties: { a: {} }, $defs: { x: {} } })).toEqual({ + type: "object", + properties: { a: {} }, + $defs: { x: {} }, + }); + }); +}); diff --git a/packages/mcp/test/naming.test.ts b/packages/mcp/test/naming.test.ts new file mode 100644 index 000000000..dc4502722 --- /dev/null +++ b/packages/mcp/test/naming.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { createMcpToolName, mcpNamespace } from "../src/index.ts"; + +describe("createMcpToolName", () => { + it("joins server and tool and replaces characters outside [A-Za-z0-9_]", () => { + expect(createMcpToolName("docs", "search")).toBe("mcp__docs__search"); + expect(createMcpToolName("my-server", "get.item/v2")).toBe("mcp__my_server__get_item_v2"); + }); + + it("shortens names over 64 characters with a hash of server and tool", () => { + const long = createMcpToolName("server", "x".repeat(100)); + expect(long).toHaveLength(64); + expect(long).toMatch(/^mcp__server__x+_[0-9a-f]{8}$/); + // Names that share their first 55 characters still differ. + expect(createMcpToolName("server", `${"x".repeat(100)}y`)).not.toBe(long); + // The hash covers the unsanitized names, and is stable. + expect(createMcpToolName("server", "x".repeat(100))).toBe(long); + expect(createMcpToolName("ser-ver", "x".repeat(100))).not.toBe(createMcpToolName("ser_ver", "x".repeat(100))); + }); + + it("adds the hash suffix when the sanitized name is taken", () => { + const taken = createMcpToolName("s", "a_b"); + expect(taken).toBe("mcp__s__a_b"); + const second = createMcpToolName("s", "a-b", (name) => name === taken); + expect(second).toMatch(/^mcp__s__a_b_[0-9a-f]{8}$/); + expect(createMcpToolName("s", "a_b", (name) => name === taken)).not.toBe(second); + }); +}); + +describe("mcpNamespace", () => { + it("is mcp__ with - replaced by _, the prefix of the server's tool names", () => { + expect(mcpNamespace("docs")).toBe("mcp__docs"); + expect(mcpNamespace("my-server")).toBe("mcp__my_server"); + expect(mcpNamespace("my-server")).toBe(mcpNamespace("my_server")); + expect(createMcpToolName("my-server", "search").startsWith(`${mcpNamespace("my-server")}__`)).toBe(true); + }); +}); diff --git a/packages/mcp/test/output-limit.test.ts b/packages/mcp/test/output-limit.test.ts new file mode 100644 index 000000000..b81d913f0 --- /dev/null +++ b/packages/mcp/test/output-limit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { type LlmContent, limitMcpContent, MCP_OUTPUT_MAX_BYTES } from "../src/index.ts"; + +const image: LlmContent = { type: "image", data: "AAAA", mimeType: "image/png" }; + +function textOf(content: LlmContent[]): string { + const [first] = content; + if (first?.type !== "text") throw new Error("expected a text block first"); + return first.text; +} + +describe("limitMcpContent", () => { + it("returns content within the limit as it is, without saving", async () => { + const content: LlmContent[] = [{ type: "text", text: "x".repeat(MCP_OUTPUT_MAX_BYTES) }, image]; + let saves = 0; + const limited = await limitMcpContent(content, async () => { + saves++; + return "/tmp/full.txt"; + }); + expect(limited).toEqual({ content }); + expect(limited.content).toBe(content); + expect(saves).toBe(0); + }); + + it("cuts the middle of longer text, saves the full text and keeps images", async () => { + const full = Array.from({ length: 3000 }, (_, index) => `line ${index + 1}`).join("\n"); + const saved: [string | Uint8Array, string][] = []; + const split = full.indexOf("\n", 100); + const limited = await limitMcpContent( + [{ type: "text", text: full.slice(0, split) }, image, { type: "text", text: full.slice(split + 1) }], + async (data, extension) => { + saved.push([data, extension]); + return "/tmp/full.txt"; + }, + ); + // Text blocks are joined with newlines before the limit applies. + expect(saved).toEqual([[full, ".txt"]]); + expect(limited.fullOutputPath).toBe("/tmp/full.txt"); + expect(limited.content).toHaveLength(2); + expect(limited.content[1]).toEqual(image); + const text = textOf(limited.content); + expect(text).toMatch( + new RegExp( + `^Warning: truncated output \\(original token count: ${Math.ceil(full.length / 4)}\\)\nTotal output lines: 3000\n\nline 1\nline 2\n`, + ), + ); + expect(text).toMatch(/…\d+ chars truncated…/); + expect(text.endsWith("line 3000\n\n[Full output: /tmp/full.txt (read it with offset/limit)]")).toBe(true); + expect(Buffer.byteLength(text)).toBeLessThan(MCP_OUTPUT_MAX_BYTES + 1024); + }); + + it("reports a failed save, and names no file without a saver", async () => { + const content: LlmContent[] = [{ type: "text", text: "y".repeat(MCP_OUTPUT_MAX_BYTES + 1) }]; + const failed = await limitMcpContent(content, async () => { + throw new Error("disk full"); + }); + expect(failed.fullOutputPath).toBeUndefined(); + expect(textOf(failed.content).endsWith("\n\n[Could not save the full output: disk full]")).toBe(true); + + const unsaved = await limitMcpContent(content); + expect(unsaved.fullOutputPath).toBeUndefined(); + expect(textOf(unsaved.content)).toMatch(/^Warning: truncated output .*…\d+ chars truncated…y+$/s); + }); + + it("cuts multi-byte text only at character boundaries", async () => { + // After the one-byte prefix, characters start at odd bytes, and both cuts (byte 10240 and byte + // 40004 - 10240) are even: each lands inside a two-byte character. + const text = `a${"é".repeat(20_000)}end`; + const limited = textOf((await limitMcpContent([{ type: "text", text }])).content); + expect(limited).not.toContain("\uFFFD"); + expect(limited.endsWith("end")).toBe(true); + const marker = /…(\d+) chars truncated…/.exec(limited); + if (!marker) throw new Error("expected the truncation marker"); + const [head, tail] = limited.slice(limited.indexOf("\n\n") + 2).split(marker[0]); + expect(Buffer.byteLength(head)).toBeLessThanOrEqual(MCP_OUTPUT_MAX_BYTES / 2); + expect(Buffer.byteLength(tail)).toBeLessThanOrEqual(MCP_OUTPUT_MAX_BYTES / 2); + expect(Array.from(head).length + Array.from(tail).length + Number(marker[1])).toBe(Array.from(text).length); + }); +});