@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
163 lines • 7.51 kB
JavaScript
/**
* MCP server configuration and the servers extensions register with `pi.registerMcpServer()`.
*
* 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 { 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.
*/
function toolPatternRegExp(pattern) {
const source = pattern
.split("*")
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return new RegExp(`^${source}$`);
}
/** Whether a tool name matches any of the entries, each an exact name or a pattern. */
export function createToolNameMatcher(entries) {
const names = new Set(entries.filter((entry) => !entry.includes("*")));
const patterns = entries.filter((entry) => entry.includes("*")).map(toolPatternRegExp);
return (name) => names.has(name) || patterns.some((pattern) => pattern.test(name));
}
/** MCP resource tools, which reach every server with resources. */
export const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources";
export const LIST_MCP_RESOURCE_TEMPLATES_TOOL = "list_mcp_resource_templates";
export const READ_MCP_RESOURCE_TOOL = "read_mcp_resource";
const MCP_RESOURCE_TOOLS = new Set([
LIST_MCP_RESOURCES_TOOL,
LIST_MCP_RESOURCE_TEMPLATES_TOOL,
READ_MCP_RESOURCE_TOOL,
]);
/** Whether a tool comes from MCP: a server tool (`mcp__<server>__<tool>`) or a resource tool. */
export function isMcpToolName(name) {
return name.startsWith("mcp__") || MCP_RESOURCE_TOOLS.has(name);
}
const MCP_EXPOSURES = ["codemode", "deferred", "direct", "hidden"];
/** Older exposure names, accepted in configs and replaced by their current name when validated. */
const MCP_EXPOSURE_ALIASES = { "codemode-deferred": "codemode" };
/**
* Whether pi's Client ID Metadata Documents list a redirect URI for the loopback `callbackUrl`: they list
* `/callback` on localhost and 127.0.0.1, on any port. Without `callbackUrl`, pi uses such a URI.
*/
export function isClientMetadataDocumentCallback(callbackUrl) {
if (callbackUrl === undefined)
return true;
const url = new URL(callbackUrl);
return url.hostname !== "[::1]" && url.pathname === "/callback";
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isExposure(value) {
return typeof value === "string" && MCP_EXPOSURES.includes(value);
}
/** The exposure an alias stands for; other values are returned unchanged. */
function resolveExposureAlias(value) {
return typeof value === "string" ? (MCP_EXPOSURE_ALIASES[value] ?? value) : value;
}
/** A copy of the server entry with exposure aliases replaced by their current names. */
function resolveExposureAliases(value) {
const { exposure, toolExposure } = value;
const resolved = { ...value };
if (exposure !== undefined)
resolved.exposure = resolveExposureAlias(exposure);
if (isRecord(toolExposure)) {
resolved.toolExposure = Object.fromEntries(Object.entries(toolExposure).map(([tool, entry]) => [tool, resolveExposureAlias(entry)]));
}
return resolved;
}
/** Exposure of one tool of a server: its `toolExposure` entry, else the server's `exposure`. */
export function getMcpToolExposure(config, toolName) {
const overrides = config.toolExposure ?? {};
const exact = overrides[toolName];
if (exact !== undefined)
return exact;
for (const [pattern, exposure] of Object.entries(overrides)) {
if (pattern.includes("*") && toolPatternRegExp(pattern).test(toolName))
return exposure;
}
return config.exposure ?? "codemode";
}
/** Checks `exposure` and `toolExposure`, after aliases were resolved. Returns an error message. */
function checkExposures(entry) {
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, url) {
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: 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, raw) {
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;
}
/** Servers registered by the extensions of one runtime. */
export class McpServerRegistry {
servers = new Map();
changeListener;
/** Register or replace a server. The caller checks ownership. */
register(server) {
this.servers.set(server.name, server);
this.changeListener?.();
}
/** Remove a server registered by `extensionPath`. Servers of other extensions are left alone. */
unregister(name, extensionPath) {
if (this.servers.get(name)?.extensionPath !== extensionPath)
return;
this.servers.delete(name);
this.changeListener?.();
}
get(name) {
return this.servers.get(name);
}
/** Copies of the registered servers, in registration order. */
list() {
return [...this.servers.values()].map((server) => ({ ...server, config: structuredClone(server.config) }));
}
/** Called after every change. The runner sets it when it binds, to emit `mcp_servers_change`. */
setChangeListener(listener) {
this.changeListener = listener;
}
}
//# sourceMappingURL=mcp-servers.js.map