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.
255 lines (238 loc) • 10.9 kB
text/typescript
/**
* Registers MCP management and resource tools on Pi's ExtensionAPI.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as Type from "typebox";
import type { ConnectionCoordinator } from "./connection-coordinator.js";
import { AuthCancelledError, AuthLockError, AuthRequiredError, McpError } from "./errors.js";
import { formatErrorMessage } from "./startup-auth.js";
import type {
ResourceBridge,
McpResourceDefinition,
McpResourceTemplateDefinition,
PiResourceContent,
} from "./resource-bridge.js";
import type { ManagedServer, ServerManager } from "./server-manager.js";
import { lookupServerSource, type ConfigProvenance } from "./server-provenance.js";
import {
collectServerStatus,
getServerInstructions,
toStatusRow,
type ServerStatusRow,
} from "./server-status.js";
import type { ToolBridge } from "./tool-bridge.js";
export interface ManagementToolDeps {
manager: ServerManager;
coordinator: ConnectionCoordinator;
bridge: ToolBridge;
resources: ResourceBridge;
getProvenance: () => ConfigProvenance;
}
/** One `mcp_status` row: state, tool counts, credential summary, error hint, and config source. */
function collectStatusRow(
server: ManagedServer,
deps: ManagementToolDeps,
provenance: ConfigProvenance,
): Promise<ServerStatusRow> {
return collectServerStatus({
server,
tools: deps.bridge.getToolActivity(server.name),
source: lookupServerSource(provenance, server.name),
readAuthStatus: (name) => deps.manager.getServerAuthStatus(name),
}).then(toStatusRow);
}
export interface McpConnectErrorOptions {
aborted: boolean;
serverExists: boolean;
hasUI: boolean;
}
function formatMcpAuthError(err: unknown, hasUI: boolean): string | undefined {
if (err instanceof AuthLockError) {
return "Another Pi process is authorizing this server. Retry after it finishes.";
}
if (err instanceof AuthRequiredError) {
return hasUI
? "MCP authorization required. Retry with mcp_connect or /mcp:auth."
: "MCP authorization requires an interactive Pi session.";
}
return undefined;
}
function formatMcpConnectFailureMessage(err: unknown): string {
if (err instanceof McpError && err.code === "protocol") {
return "MCP tool discovery failed. Check the server and retry.";
}
// WHY: a request timeout means the server was reachable but too slow, so pointing at the
// configuration would send the user down the wrong path (#209).
if (err instanceof McpError && err.code === "timeout") {
return "MCP server did not respond in time. Check that it is running and retry.";
}
return "MCP connection failed. Check the server configuration and retry.";
}
export function formatMcpConnectError(err: unknown, options: McpConnectErrorOptions): string {
if (options.aborted || err instanceof AuthCancelledError) {
return "MCP connection cancelled. Retry when ready.";
}
if (!options.serverExists) {
return "Unknown MCP server name. Use mcp_status to list configured servers.";
}
return formatMcpAuthError(err, options.hasUI) ?? formatMcpConnectFailureMessage(err);
}
export function registerManagementTools(
pi: ExtensionAPI,
deps: ManagementToolDeps,
): void {
const { manager, coordinator, bridge } = deps;
pi.registerTool({
name: "mcp_status",
label: "MCP status",
description: "List configured MCP servers, lifecycle, connection and OAuth state. Use mcp_connect to connect one server.",
promptSnippet: "Inspect configured MCP servers, their connection state, discovered tools, and authentication status.",
parameters: Type.Object({}),
async execute() {
const provenance = deps.getProvenance();
const rows = await Promise.all(
manager.getAllServers().map((server) => collectStatusRow(server, deps, provenance)),
);
return { content: [{ type: "text", text: JSON.stringify(rows) }], details: {} };
},
});
pi.registerTool({
name: "mcp_connect",
label: "Connect MCP server",
description: "Connect one named configured MCP server and activate its tools. May request interactive OAuth authorization.",
promptSnippet: "Connect or reconnect to a configured MCP server by name and register its tools.",
parameters: Type.Object({ name: Type.String({ description: "Exact configured MCP server name" }) }),
async execute(_id, { name }, signal, _update, ctx) {
try {
await coordinator.connect(name, ctx, { signal });
} catch (err) {
const message = formatMcpConnectError(err, {
aborted: Boolean(signal?.aborted),
serverExists: Boolean(manager.getServer(name)),
hasUI: ctx.hasUI,
});
throw new Error(message);
}
const activity = bridge.getToolActivity(name);
if (activity.active !== activity.discovered) {
throw new Error("MCP server connected, but some discovered tools are inactive. Check Pi tool restrictions or extension conflicts; mcp_status reports discovered and active counts.");
}
const instructions = getServerInstructions(manager.getServer(name));
const ready = `MCP server ${name} ready; tools discovered and active.`;
const text = instructions === undefined ? ready : `${ready}\nInstructions: ${instructions}`;
return { content: [{ type: "text", text }], details: {} };
},
});
pi.registerTool({
name: "mcp_list_resources",
label: "List MCP resources",
description: "List MCP resources and URI templates exposed by connected servers. Use mcp_read_resource to read one by URI.",
promptSnippet: "Discover read-only MCP resources (documents, schemas, data streams) and URI templates, optionally for one server.",
parameters: Type.Object({
server: Type.Optional(Type.String({ description: "Exact configured MCP server name (default: all connected servers)" })),
}),
annotations: { readOnlyHint: true },
async execute(_id, { server }, signal) {
const rows = await listResourceRows(deps, server, signal);
return { content: [{ type: "text", text: JSON.stringify(rows) }], details: {} };
},
});
pi.registerTool({
name: "mcp_read_resource",
label: "Read MCP resource",
description: "Read one MCP resource by server and URI. Text resources return their text; image blobs return an image; other binary blobs return a size summary.",
promptSnippet: "Read an MCP resource document or data stream by its URI from a connected server.",
parameters: Type.Object({
server: Type.String({ description: "Exact configured MCP server name" }),
uri: Type.String({ description: "Resource URI, or a URI template filled in by the caller" }),
}),
annotations: { readOnlyHint: true },
async execute(_id, { server, uri }, signal) {
const content = await readResourceForTool(deps, server, uri, signal);
return { content, details: {} };
},
});
}
/** One `mcp_list_resources` row: a connected server's resources and templates. */
interface ResourceToolRow {
server: string;
resources: McpResourceDefinition[];
templates: McpResourceTemplateDefinition[];
resourcesUnsupported?: boolean;
error?: string;
}
/**
* Collect one server's catalog.
* WHY: a server that never advertised the `resources` capability must not be asked — the raw
* request would come back as a protocol error rather than an empty catalog. A server that does
* advertise it but fails keeps its row instead of rejecting every other server's listing.
*/
async function collectResourcesForServer(
server: ManagedServer,
deps: ManagementToolDeps,
signal: AbortSignal | undefined,
): Promise<ResourceToolRow> {
if (signal?.aborted) throw new AuthCancelledError(server.name);
const client = server.client;
if (!client || !client.getServerCapabilities()?.resources) {
return { server: server.name, resources: [], templates: [], resourcesUnsupported: true };
}
try {
const catalog = await deps.resources.getCatalog(server.name, client, {
requestTimeoutMs: deps.manager.resolveRequestTimeoutMs(server),
...(signal ? { signal } : {}),
});
return { server: server.name, ...catalog };
} catch (err) {
if (err instanceof AuthCancelledError || signal?.aborted) {
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(server.name);
}
return { server: server.name, resources: [], templates: [], error: formatErrorMessage(err) };
}
}
/** Gather `mcp_list_resources` rows for every connected server, or one explicitly named server. */
async function listResourceRows(
deps: ManagementToolDeps,
filter: string | undefined,
signal: AbortSignal | undefined,
): Promise<{ servers: ResourceToolRow[]; stoppedServers: string[] }> {
if (signal?.aborted) throw new AuthCancelledError(filter ?? "mcp_list_resources");
const all = deps.manager.getAllServers();
if (filter) {
const target = all.find((server) => server.name === filter);
if (!target) throw new Error("Unknown MCP server name. Use mcp_status to list configured servers.");
if (target.state !== "ready") {
throw new Error(`MCP server ${filter} is not connected. Use mcp_connect to connect it.`);
}
return { servers: [await collectResourcesForServer(target, deps, signal)], stoppedServers: [] };
}
const connected = all.filter((server) => server.state === "ready");
const servers = await Promise.all(connected.map((server) => collectResourcesForServer(server, deps, signal)));
const stoppedServers = all.filter((server) => server.state !== "ready").map((server) => server.name);
return { servers, stoppedServers };
}
/** Resolve the server for `mcp_read_resource` and read one URI from its live client. */
async function readResourceForTool(
deps: ManagementToolDeps,
serverName: string,
uri: string,
signal: AbortSignal | undefined,
): Promise<PiResourceContent[]> {
if (signal?.aborted) throw new AuthCancelledError(serverName);
const target = deps.manager.getServer(serverName);
if (!target) throw new Error("Unknown MCP server name. Use mcp_status to list configured servers.");
const client = target.client;
if (!client || target.state !== "ready") {
throw new Error(`MCP server ${serverName} is not connected. Use mcp_connect to connect it.`);
}
// WHY: without this a capability-less server answers resources/read with JSON-RPC -32601,
// which surfaces as a cryptic protocol error instead of the same actionable wording the
// listing and /mcp:resources use.
if (!client.getServerCapabilities()?.resources) {
throw new McpError(`MCP server ${serverName} does not expose MCP resources`, serverName, "protocol");
}
return deps.resources.readResource(serverName, client, uri, {
requestTimeoutMs: deps.manager.resolveRequestTimeoutMs(target),
...(signal ? { signal } : {}),
});
}