pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
441 lines (408 loc) • 15.8 kB
text/typescript
/**
* Configuration loading, validation, and merging for pi-mcp.
*
* Config file locations (Pi-native convention, highest priority first):
* 1. <cwd>/.pi/mcp.json — project-level config
* 2. <agent dir>/mcp.json — global config; the agent dir is what Pi itself uses:
* $PI_CODING_AGENT_DIR when set (Pi profiles / alternative agent dirs), else ~/.pi/agent
*
* Project servers/settings override global servers/settings per-key (shallow merge).
* A Pi 1.3.0 partial project entry (`{ "enabled": false }` — no `command`/`url`) overrides only the
* per-project keys of the global server of that name, keeping the global entry's credentials.
* No deep merge of any other keys, no env var interpolation — WYSIWYG config.
*/
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import { callbackServerConfigFromRedirectUrl } from "./callback-url.js";
import {
applyProjectOverrides,
normalizeAuthConfigAliases,
normalizeServerConfigAliases,
withRegisteredServerLifecycle,
} from "./config-compat.js";
import { McpError } from "./errors.js";
import { stripJsonComments } from "./jsonc.js";
// ─── Zod Schemas ──────────────────────────────────────────────────────────────
const AuthConfigObjectSchema = z.object({
/** Auth type. Currently only "oauth" is supported. Default: "oauth". */
type: z.enum(["oauth"]).default("oauth"),
/**
* Callback URL for the OAuth redirect.
* Default: auto-detected local callback server.
*/
redirectUrl: z.string().optional(),
/**
* Optional scope to request during authorization.
*/
scope: z.string().optional(),
/**
* Pre-registered client_id (skip dynamic client registration).
*/
clientId: z.string().optional(),
/**
* Pre-registered client_secret.
*/
clientSecret: z.string().optional(),
/**
* `client_name` sent with Dynamic Client Registration (Pi 1.3.0 `oauth.clientName`).
* Default: `pi-mcp/<server name>`.
*/
clientName: z.string().trim().min(1).optional(),
/**
* Pi's `/login` provider auth (`auth.provider`), accepted only so it can be rejected with a
* clear error: this extension runs OAuth 2.1 and cannot forward a Pi provider token.
*/
provider: z.unknown().optional(),
});
function validateAuthConfig(
auth: z.output<typeof AuthConfigObjectSchema>,
ctx: z.RefinementCtx,
): void {
if (auth.provider !== undefined) {
ctx.addIssue({
code: "custom",
path: ["provider"],
message:
'"auth.provider" sends a Pi /login provider token, which pi-mcp-extension does not support; ' +
'remove it to use OAuth 2.1, or send a static credential with "headers"',
});
}
if (auth.redirectUrl !== undefined) {
try {
callbackServerConfigFromRedirectUrl(auth.redirectUrl);
} catch (err) {
ctx.addIssue({
code: "custom",
path: ["redirectUrl"],
message: err instanceof Error ? err.message : String(err),
});
}
}
}
const AuthConfigSchema = z.preprocess(
normalizeAuthConfigAliases,
AuthConfigObjectSchema.superRefine(validateAuthConfig),
);
const ServerConfigObjectSchema = z.object({
/** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
command: z.string().optional(),
/** Arguments passed to command. */
args: z.array(z.string()).default([]),
/**
* Working directory for a stdio server (Pi 1.3.0 `cwd`). A leading `~` expands to the home
* directory and a relative path resolves against the session working directory.
*/
cwd: z.string().min(1).optional(),
/**
* Extra environment variables passed to the child process as literals.
* These merge with process.env; project env overrides parent env.
* No ${VAR} interpolation — set vars in your shell environment instead.
*/
env: z.record(z.string(), z.string()).optional(),
/** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
/**
* URL for streamable-http or sse transports.
* Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
*/
url: z.string().url().optional(),
/**
* Static HTTP headers to include with every request (streamable-http / sse only).
* Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
* For OAuth2, use the "auth" field instead: when "auth" is set, OAuth owns the
* Authorization header and any Authorization entry here is ignored (other headers
* still apply).
*/
headers: z.record(z.string(), z.string()).optional(),
/**
* OAuth2 configuration for servers that require authorization.
* When set, the transport will use the SDK's OAuth flow (discovery,
* dynamic client registration, PKCE, token refresh).
* Only applies to streamable-http and sse transports.
*/
auth: AuthConfigSchema.optional(),
/**
* "eager" — start at session_start.
* "lazy" — start manually via /mcp:start command.
*/
lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
/**
* Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to
* `lifecycle: "lazy"`). Default: `true`.
*/
enabled: z.boolean().optional(),
/** Per-request timeout in ms. Overrides global setting. Default: 30000. */
requestTimeoutMs: z.number().positive().optional(),
/**
* Opt-in heartbeat interval (ping) in ms.
* Only useful for long-lived connections where you want proactive liveness checks.
* Default: disabled.
*/
healthCheckIntervalMs: z.number().positive().optional(),
});
function validateServerConfig(
cfg: z.output<typeof ServerConfigObjectSchema>,
ctx: z.RefinementCtx,
): void {
if (cfg.transport === "stdio") {
if (cfg.command === undefined) {
ctx.addIssue({
code: "custom",
message: '"command" is required for stdio transport',
});
}
if (cfg.auth !== undefined) {
ctx.addIssue({
code: "custom",
path: ["auth"],
message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
});
}
return;
}
if (cfg.url === undefined) {
ctx.addIssue({
code: "custom",
message: `"url" is required for ${cfg.transport} transport`,
});
}
if (cfg.cwd !== undefined) {
ctx.addIssue({
code: "custom",
path: ["cwd"],
message: `"cwd" is only supported for 'stdio' transport, not '${cfg.transport}'`,
});
}
}
export const ServerConfigSchema = z.preprocess(
normalizeServerConfigAliases,
ServerConfigObjectSchema.superRefine(validateServerConfig),
);
const DEFAULT_SETTINGS = {
toolPrefix: "mcp",
requestTimeoutMs: 30000,
maxRetries: 5,
} as const;
const SettingsSchema = z.object({
/**
* Prefix used in Pi tool names: <prefix>_<server>_<tool>.
* Must match ^[a-zA-Z][a-zA-Z0-9_]*$. Default: "mcp".
*/
toolPrefix: z
.string()
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
.default(DEFAULT_SETTINGS.toolPrefix),
/** Default per-request timeout in ms for all servers. Default: 30000. */
requestTimeoutMs: z.number().positive().default(DEFAULT_SETTINGS.requestTimeoutMs),
/** Maximum retry attempts when a server fails to connect. Default: 5. */
maxRetries: z.number().int().min(0).max(10).default(DEFAULT_SETTINGS.maxRetries),
});
const McpConfigSchema = z.object({
settings: SettingsSchema.default(() => ({ ...DEFAULT_SETTINGS })),
mcpServers: z.record(z.string(), ServerConfigSchema).default({}),
});
/**
* Project-level file: `settings` keys are optional and carry NO defaults, so a project file
* only overrides what it actually states. (Parsing it with SettingsSchema filled in the
* defaults — e.g. toolPrefix "mcp" — and the merge then overwrote the global settings.)
*/
const ProjectSettingsSchema = z.object({
toolPrefix: z
.string()
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
.optional(),
requestTimeoutMs: z.number().positive().optional(),
maxRetries: z.number().int().min(0).max(10).optional(),
});
const ProjectConfigSchema = z.object({
settings: ProjectSettingsSchema.default({}),
mcpServers: z.record(z.string(), ServerConfigSchema).default({}),
});
type ProjectConfig = z.output<typeof ProjectConfigSchema>;
// ─── Public Types ─────────────────────────────────────────────────────────────
export type AuthConfig = z.output<typeof AuthConfigSchema>;
export type ServerConfig = z.output<typeof ServerConfigSchema>;
export type Settings = z.output<typeof SettingsSchema>;
export type McpConfig = z.output<typeof McpConfigSchema>;
/** Which config file supplied a server entry. */
export type FileConfigOrigin = "global" | "project";
/** A loaded config plus where each server entry came from. */
export interface LoadedConfig {
config: McpConfig;
/** Winning origin per configured server name (project entries override global ones). */
origins: Record<string, FileConfigOrigin>;
/** Path of the project config file for the loaded cwd, read or not. */
projectConfigPath: string;
/** Whether the project config was read (`includeProjectConfig !== false`). */
projectConfigIncluded: boolean;
}
export interface LoadConfigOptions {
/**
* Whether to read project-level `<cwd>/.pi/mcp.json`.
* Set to `false` when the project is untrusted (`ctx.isProjectTrusted() === false`)
* or during bootstrap before session trust is resolved.
* Default: `true`.
*/
includeProjectConfig?: boolean;
}
// ─── Loader ───────────────────────────────────────────────────────────────────
async function readJsonFile(path: string): Promise<unknown> {
let text: string;
try {
text = await readFile(path, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
const detail = err instanceof Error ? err.message : String(err);
throw new McpError(
`Failed to read mcp.json at ${path}: ${detail}`,
"<config>",
"config",
err,
);
}
try {
return JSON.parse(stripJsonComments(text));
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new McpError(
`Invalid JSON in mcp.json at ${path}: ${detail}`,
"<config>",
"config",
err,
);
}
}
function parseConfig(raw: unknown, sourcePath: string): McpConfig {
return parseWith(McpConfigSchema, raw, sourcePath);
}
function parseWith<S extends z.ZodTypeAny>(
schema: S,
raw: unknown,
sourcePath: string,
): z.output<S> {
const result = schema.safeParse(raw);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new McpError(
`Invalid mcp.json at ${sourcePath}:\n${issues}`,
"<config>",
"config",
);
}
return result.data;
}
/**
* Parse and normalize a server configuration registered via `pi.registerMcpServer()`.
* Extension-registered servers default to `lifecycle: "eager"` (unless `enabled: false`
* or `lifecycle` is explicitly specified) so they connect on `session_start` / `mcp_servers_change`.
*/
export function parseRegisteredServerConfig(
name: string,
raw: unknown,
sourcePath = "<extension>",
): ServerConfig {
const result = ServerConfigSchema.safeParse(withRegisteredServerLifecycle(raw));
if (!result.success) {
const issues = result.error.issues
.map((i) => ` mcpServers.${name}${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`)
.join("\n");
throw new McpError(
`Invalid MCP server "${name}" from ${sourcePath}:\n${issues}`,
name,
"config",
);
}
return result.data;
}
function mergeConfigs(
globalCfg: McpConfig,
projectCfg: ProjectConfig,
): McpConfig {
// Only the settings the project file states may override the global ones.
const stated = Object.fromEntries(
Object.entries(projectCfg.settings).filter(([, v]) => v !== undefined),
) as Partial<Settings>;
return {
// Shallow spread: project settings override global settings per key
settings: { ...globalCfg.settings, ...stated },
// Per-server override: project server entry completely replaces global entry with same name
mcpServers: { ...globalCfg.mcpServers, ...projectCfg.mcpServers },
};
}
/**
* Pi's agent directory, resolved the way Pi resolves it (`getAgentDir()` in
* @earendil-works/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
* ~/.pi/agent. Pi profiles and alternative agent dirs only work through that variable.
*/
export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
const fromEnv = env.PI_CODING_AGENT_DIR;
if (fromEnv && fromEnv.length > 0) {
if (fromEnv === "~") return homedir();
if (fromEnv.startsWith("~/")) return join(homedir(), fromEnv.slice(2));
return fromEnv;
}
return join(homedir(), ".pi", "agent");
}
/** Winning file origin per server name: a project entry replaces a global entry of the same name. */
function originsOf(
globalCfg: McpConfig,
projectCfg: ProjectConfig | undefined,
): Record<string, FileConfigOrigin> {
const origins: Record<string, FileConfigOrigin> = {};
for (const name of Object.keys(globalCfg.mcpServers)) {
origins[name] = "global";
}
for (const name of Object.keys(projectCfg?.mcpServers ?? {})) {
origins[name] = "project";
}
return origins;
}
/**
* Load and merge global and project configs, reporting where every server entry came from.
* Same validation, precedence, and error behavior as `loadConfig`.
*/
export async function loadConfigWithOrigins(
cwd: string,
globalPath = join(agentDir(), "mcp.json"),
options?: LoadConfigOptions,
): Promise<LoadedConfig> {
const projectConfigIncluded = options?.includeProjectConfig !== false;
const projectConfigPath = join(cwd, ".pi", "mcp.json");
const [globalRaw, projectRaw] = await Promise.all([
readJsonFile(globalPath),
projectConfigIncluded ? readJsonFile(projectConfigPath) : Promise.resolve(undefined),
]);
const globalCfg = globalRaw !== undefined
? parseConfig(globalRaw, globalPath)
: McpConfigSchema.parse({});
const projectCfg = projectRaw === undefined
? undefined
: parseWith(
ProjectConfigSchema,
applyProjectOverrides(projectRaw, globalRaw, projectConfigPath),
projectConfigPath,
);
return {
config: projectCfg === undefined ? globalCfg : mergeConfigs(globalCfg, projectCfg),
origins: originsOf(globalCfg, projectCfg),
projectConfigPath,
projectConfigIncluded,
};
}
/**
* Load and merge global (<agent dir>/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
* Project config takes precedence over global config when `options.includeProjectConfig !== false`.
* Returns a fully validated, merged config.
*/
export async function loadConfig(
cwd: string,
globalPath = join(agentDir(), "mcp.json"),
options?: LoadConfigOptions,
): Promise<McpConfig> {
const loaded = await loadConfigWithOrigins(cwd, globalPath, options);
return loaded.config;
}