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.
This commit is contained in:
Armin Ronacher 2026-11-03 01:51:23 +01:00
parent bb266bf359
commit 76c040a2ad
21 changed files with 804 additions and 305 deletions

View file

@ -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";

View file

@ -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<Record<string, McpExposure>> = { "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<string, McpExposure>;
/** 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<string, string>;
/** 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:<port>/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<string, string>;
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__<server>` 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStringRecord(value: unknown): value is Record<string, string> {
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, unknown>): 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()`. */

View file

@ -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,
};
}

View file

@ -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<string, unknown>): boolean {
return value.command === undefined && value.url === undefined && value.type === undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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;

View file

@ -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";

View file

@ -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;

View file

@ -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<string, unknown>,
): 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 {

View file

@ -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<string>;
/** 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<string> {
return writeOutputFile("pi-mcp", extension, data);
}
@ -75,23 +68,6 @@ export interface McpToolCaller {
callTool(name: string, args: Record<string, unknown>, options: McpRequestOptions): Promise<CallToolResult>;
}
/**
* `mcp__<server>__<tool>`, 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<string, un
} as unknown as TSchema;
}
/**
* 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.
*/
export async function limitMcpContent(
content: (TextContent | ImageContent)[],
saveOutput: McpOutputSaver = saveToTempFile,
): Promise<{ content: (TextContent | ImageContent)[]; fullOutputPath?: string }> {
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<string, unknown>): 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,

View file

@ -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", () => {

View file

@ -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";

View file

@ -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__<server>__<tool>`, 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

View file

@ -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__<server>__<tool>`, 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

243
packages/mcp/src/config.ts Normal file
View file

@ -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<string, string>;
/** 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:<port>/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<string, string>;
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<string, unknown> }
| { kind: "http"; config: McpHttpServerConfig & Record<string, unknown> }
| { 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<string, unknown> };
}
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<string, unknown> };
}
return invalid(`server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateBaseFields(entry: Record<string, unknown>): 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<string, unknown>,
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, unknown>): 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<string, string> {
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<string, unknown>): boolean {
return entry.command === undefined && entry.url === undefined && entry.type === undefined;
}

View file

@ -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,

View file

@ -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<string, unknown>): Record<string, unknown> {
return {
...schema,
type: schema.type ?? "object",
...(schema.properties === undefined ? { properties: {} } : {}),
};
}

View file

@ -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__<server>__<tool>`, 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__<server>` 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, "_")}`;
}

View file

@ -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<string>;
/** 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<LimitedMcpContent> {
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 };
}

View file

@ -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<string, unknown>) => ({ 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);
});
});

View file

@ -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: {} },
});
});
});

View file

@ -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__<server> 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);
});
});

View file

@ -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);
});
});