mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
bb266bf359
commit
76c040a2ad
21 changed files with 804 additions and 305 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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()`. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
243
packages/mcp/src/config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
13
packages/mcp/src/input-schema.ts
Normal file
13
packages/mcp/src/input-schema.ts
Normal 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: {} } : {}),
|
||||
};
|
||||
}
|
||||
35
packages/mcp/src/naming.ts
Normal file
35
packages/mcp/src/naming.ts
Normal 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, "_")}`;
|
||||
}
|
||||
77
packages/mcp/src/output-limit.ts
Normal file
77
packages/mcp/src/output-limit.ts
Normal 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 };
|
||||
}
|
||||
157
packages/mcp/test/config.test.ts
Normal file
157
packages/mcp/test/config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
packages/mcp/test/input-schema.test.ts
Normal file
24
packages/mcp/test/input-schema.test.ts
Normal 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: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
37
packages/mcp/test/naming.test.ts
Normal file
37
packages/mcp/test/naming.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
packages/mcp/test/output-limit.test.ts
Normal file
79
packages/mcp/test/output-limit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue