pi-mono/apps/p2/plugins/mcp/config.ts
Armin Ronacher 7dc38ff0e9 feat(p2): MCP servers on the host, on environments and from projects
A manager on the p2 server owns every MCP connection and process. Session
workers hold one view each and lease the locations their conversations
work at; the manager runs the owner's mcp.json servers there (stdio per
session and location, or shared; HTTP shared by default) and offers their
tools as deferred Durable tools found through tool_search.

Stdio servers on an environment start through its pi-env daemon, one
connection per environment shared by its instances, through cmd.exe on
Windows. A project's .pi/mcp.json, read by the worker at each location,
may add stdio servers that run there and switch the owner's servers on or
off; the manager refuses everything else. A lease gets servers only on
environments its session works on.
2026-11-03 16:48:38 +01:00

189 lines
7.7 KiB
TypeScript

// The owner's MCP configuration, `<P2_DATA_DIR>/mcp.json` (a trust boundary): the shared `mcpServers` shape checked by
// pi-mcp, p2's own fields (`shared`, `runOn`) checked here, and the file followed while the server runs.
import { join } from "node:path";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import {
type McpHttpServerConfig,
type McpStdioServerConfig,
mcpNamespace,
validateMcpServerConfig,
} from "@earendil-works/pi-mcp";
import { Observer, optionalText, stripBom, stripJsonComments } from "@p2/app/v1/node";
/** Where a stdio server runs: where the conversation works, on the p2 host, or on an environment of `envs.json`. */
type RunOn = "location" | "host" | { readonly env: string };
/** One server of the owner's configuration, validated. */
export type OwnerServer =
| {
readonly name: string;
readonly kind: "stdio";
readonly config: McpStdioServerConfig;
/** `"enabled": false` keeps it off, unless a project's `mcp.json` turns it on where it applies. */
readonly enabled: boolean;
/** One process for every session (`"shared": true`), else one per session and location. */
readonly shared: boolean;
readonly runOn: RunOn;
}
| {
readonly name: string;
readonly kind: "http";
readonly config: McpHttpServerConfig;
readonly enabled: boolean;
/** One connection for every session (the default), else one per session. */
readonly shared: boolean;
};
/** The owner's servers and why entries were skipped. */
export type OwnerConfig = { readonly servers: readonly OwnerServer[]; readonly errors: readonly string[] };
/**
* The file as last read: not yet, a valid configuration, or why it is not one (its readers keep the last valid servers,
* also from before this reader existed: a reload's new reader may first see a save caught half-way).
*/
type OwnerConfigRead =
| { readonly kind: "unread" }
| { readonly kind: "valid"; readonly config: OwnerConfig }
| { readonly kind: "invalid"; readonly failure: string };
/**
* The owner's configuration in `text` (`{ "mcpServers": { name: entry } }`, comments allowed): invalid entries are
* skipped and reported in `errors`; disabled ones are kept (a project may turn them on). Throws when the file itself is
* not such an object.
*/
export function parseOwnerConfig(text: string): OwnerConfig {
const servers: OwnerServer[] = [];
const errors: string[] = [];
const namespaces = new Map<string, string>();
for (const [name, raw] of Object.entries(serverEntries(text, "mcp.json"))) {
const entry = ownerServer(name, raw);
if (entry.kind === "invalid") {
errors.push(entry.error);
continue;
}
// Tool names and code mode namespaces replace `-` by `_`: both spellings would collide.
const taken = namespaces.get(mcpNamespace(name));
if (taken !== undefined) {
errors.push(`server "${name}": its tools would be named like those of "${taken}"`);
continue;
}
namespaces.set(mcpNamespace(name), name);
servers.push(entry.server);
}
return { servers, errors };
}
/**
* The `mcpServers` entries of `text`, a configuration file named `file` (comments allowed); throws when the file is not
* such an object.
*/
export function serverEntries(text: string, file: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(stripJsonComments(stripBom(text)));
} catch {
throw new Error(`Invalid ${file} syntax`);
}
if (!isRecord(value)) throw new Error(`${file} must be an object`);
const entries = value.mcpServers ?? {};
if (!isRecord(entries)) throw new Error(`${file}: "mcpServers" must be an object`);
return entries;
}
/** One entry of the configuration, checked. */
type Entry =
| { readonly kind: "server"; readonly server: OwnerServer }
| { readonly kind: "invalid"; readonly error: string };
/** One entry: the server, or why it is invalid. */
function ownerServer(name: string, raw: unknown): Entry {
const invalid = (error: string): Entry => ({ kind: "invalid", error: `server "${name}": ${error}` });
const result = validateMcpServerConfig(name, raw);
if (result.kind === "invalid") return { kind: "invalid", error: result.error };
// The entry keeps fields pi-mcp does not know (p2's `shared` and `runOn` among them), unused from here on.
const { shared, runOn } = result.config;
const enabled = result.config.enabled !== false;
if (shared !== undefined && typeof shared !== "boolean") return invalid("shared must be a boolean");
if (result.kind === "http") {
if (runOn !== undefined) return invalid("runOn is for stdio servers");
return { kind: "server", server: { name, kind: "http", config: result.config, enabled, shared: shared ?? true } };
}
const placement = runOnOf(runOn);
if (placement.kind === "invalid")
return invalid('runOn must be "location", "host" or { "env": "<name in envs.json>" }');
if (shared === true && placement.kind === "set" && placement.runOn === "location")
return invalid('a shared server cannot run where each conversation works (use runOn "host" or an env)');
const defaultRunOn: RunOn = shared ? "host" : "location";
return {
kind: "server",
server: {
name,
kind: "stdio",
config: result.config,
enabled,
shared: shared ?? false,
runOn: placement.kind === "set" ? placement.runOn : defaultRunOn,
},
};
}
/** `runOn` as configured: absent (the default applies), set, or invalid. */
function runOnOf(value: unknown): { kind: "absent" } | { kind: "set"; runOn: RunOn } | { kind: "invalid" } {
if (value === undefined) return { kind: "absent" };
if (value === "location" || value === "host") return { kind: "set", runOn: value };
if (isRecord(value) && Object.keys(value).length === 1 && typeof value.env === "string" && value.env !== "")
return { kind: "set", runOn: { env: value.env } };
return { kind: "invalid" };
}
/** Whether `value` is a JSON object. */
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* `value` with every `${NAME}` replaced by the p2 host's environment variable (the owner's configuration only); an
* unset variable throws, naming it.
*/
export function expandHostVariables(value: string): string {
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => {
const variable = process.env[name];
if (variable === undefined) throw new Error(`environment variable ${name} is not set`);
return variable;
});
}
/** `mcp.json`, watched (a missing file is no servers); one process reads it (the server's manager). */
export class OwnerConfigFile {
/** Watches the file and reads it at each change; `start()` reads it the first time, `close()` stops. */
readonly observer: Observer;
#read: OwnerConfigRead = { kind: "unread" };
readonly #listeners = new Set<(read: OwnerConfigRead) => void>();
constructor(dataDir: string) {
const path = join(dataDir, "mcp.json");
this.observer = new Observer(
[{ env: new NodeExecutionEnv({ cwd: dataDir }), targets: () => [{ path }] }],
async (retrying) => {
try {
this.#read = { kind: "valid", config: parseOwnerConfig((await optionalText(path)) ?? "{}") };
} catch (error) {
// A save caught half-way is read once more before it counts.
if (!retrying) return true;
this.#read = { kind: "invalid", failure: error instanceof Error ? error.message : String(error) };
}
for (const listener of this.#listeners) listener(this.#read);
return false;
},
2000,
);
}
/** `listener` gets the last read at once (once there is one), then every read's. */
subscribe(listener: (read: OwnerConfigRead) => void): () => void {
this.#listeners.add(listener);
if (this.#read.kind !== "unread") listener(this.#read);
return () => this.#listeners.delete(listener);
}
}