UNPKG

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.

342 lines (313 loc) • 14.2 kB
/** * Registers `/mcp`, `/mcp:stop`, `/mcp:start`, and `/mcp:auth` commands on Pi's ExtensionAPI. */ import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { parseAuthArguments, parseResourceArguments, serverNameCompletions, } from "./commands.js"; import type { ConnectionCoordinator } from "./connection-coordinator.js"; import { sanitizeLogText } from "./debug-log-format.js"; import { AuthCancelledError } from "./errors.js"; import type { ManagedServer, ServerManager } from "./server-manager.js"; import { lookupServerSource, type ConfigProvenance } from "./server-provenance.js"; import { collectServerStatus, formatServerDetail } from "./server-status.js"; import { formatErrorMessage } from "./startup-auth.js"; import { sanitizeTerminalText } from "./terminal-text.js"; import type { ToolBridge } from "./tool-bridge.js"; import type { PiResourceContent, ResourceBridge, ResourceCatalog } from "./resource-bridge.js"; export interface RegisterCommandsDeps { manager: ServerManager; coordinator: ConnectionCoordinator; bridge: ToolBridge; resources: ResourceBridge; /** Session config provenance (file origins merged with extension-registered servers). */ getProvenance: () => ConfigProvenance; } /** Enriched `/mcp <name>` view: provenance, tool counts, credentials, failure hints, output. */ async function buildServerDetail(server: ManagedServer, deps: RegisterCommandsDeps): Promise<string> { const { manager } = deps; const serverName = server.name; const provenance = deps.getProvenance(); const snapshot = await collectServerStatus({ server, tools: deps.bridge.getToolActivity(serverName), source: lookupServerSource(provenance, serverName), recentOutput: manager.getServerLogs(serverName), readAuthStatus: (name) => manager.getServerAuthStatus(name), }); return formatServerDetail(snapshot, provenance); } async function handleMcpStatus(args: string, ctx: ExtensionCommandContext, deps: RegisterCommandsDeps): Promise<void> { const { manager } = deps; const serverName = args.trim(); if (!serverName) { // Summary view: all servers ctx.ui.notify(manager.getStatusSummary(), "info"); return; } const server = manager.getServer(serverName); if (!server) { ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error"); return; } ctx.ui.notify(await buildServerDetail(server, deps), "info"); } async function handleMcpStop( args: string, ctx: ExtensionCommandContext, deps: RegisterCommandsDeps, ): Promise<void> { const { manager, coordinator, bridge } = deps; const serverName = args.trim(); if (!serverName) { ctx.ui.notify("Usage: /mcp:stop <server-name>", "error"); return; } if (!manager.getServer(serverName)) { ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error"); return; } coordinator.cancelServer(serverName); coordinator.syncStatus(ctx); bridge.deactivateServer(serverName); await manager.stopServer(serverName); ctx.ui.notify(`pi-mcp: Stopped ${serverName}`, "info"); } async function handleMcpStart( args: string, ctx: ExtensionCommandContext, deps: RegisterCommandsDeps, ): Promise<void> { const { manager, coordinator } = deps; const serverName = args.trim(); if (!serverName) { ctx.ui.notify("Usage: /mcp:start <server-name>", "error"); return; } if (!manager.getServer(serverName)) { ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error"); return; } try { await coordinator.connect(serverName, ctx); ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info"); } catch (err) { if (err instanceof AuthCancelledError) { ctx.ui.notify(`pi-mcp: ${sanitizeTerminalText(err.message)}`, "info"); return; } const msg = formatErrorMessage(err); ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error"); } } async function handleMcpAuth( args: string, ctx: ExtensionCommandContext, deps: RegisterCommandsDeps, ): Promise<void> { const { manager, coordinator, bridge } = deps; const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name)); if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; } if (!manager.getServer(name)?.config.auth) { ctx.ui.notify(`No OAuth server named ${name}`, "error"); return; } if (!reset && manager.getServer(name)?.state === "ready") { // WHY: Explicit /mcp:auth on an already-ready server must tear down the existing // connection and force a fresh reconnect/auth check rather than returning a no-op. bridge.deactivateServer(name); await manager.stopServer(name); } try { await coordinator.connect(name, ctx, { reset }); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); } catch (err) { ctx.ui.notify( `pi-mcp: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 500)}`, err instanceof AuthCancelledError ? "info" : "error", ); } } // ─── /mcp:resources ────────────────────────────────────────────────────────── const RESOURCE_FIELD_MAX_LENGTH = 120; interface ResourceTarget { server: ManagedServer; client: Client; } function supportsResources(client: Client): boolean { return Boolean(client.getServerCapabilities()?.resources); } function formatResourceLine(label: string, detail: string | undefined, mimeType: string | undefined): string { const parts = [sanitizeTerminalText(label, { maxLength: RESOURCE_FIELD_MAX_LENGTH })]; if (detail) parts.push(sanitizeTerminalText(detail, { maxLength: RESOURCE_FIELD_MAX_LENGTH })); if (mimeType) parts.push(sanitizeTerminalText(mimeType, { maxLength: 60 })); return ` ${parts.join(" — ")}`; } /** Maximum catalog rows rendered in one notification; the rest are summarized. */ const MAX_CATALOG_ROWS = 50; export function formatResourceCatalog(serverName: string, catalog: ResourceCatalog): string { const header = `pi-mcp: ${sanitizeTerminalText(serverName, { maxLength: 64 })} — ${catalog.resources.length} resource(s), ${catalog.templates.length} template(s)`; const rows = [ ...catalog.resources.map((resource) => formatResourceLine(resource.uri, resource.description ?? resource.name, resource.mimeType), ), ...catalog.templates.map((template) => formatResourceLine(template.uriTemplate, template.description ?? template.name, template.mimeType), ), ]; if (rows.length === 0) return `${header}\n (none)`; const shown = rows.slice(0, MAX_CATALOG_ROWS); const hidden = rows.length - shown.length; // WHY: a spec-conformant server may advertise thousands of entries (100 pages × 100 rows), // and this string reaches a terminal notification — bound it like the read preview. if (hidden > 0) shown.push(` … and ${hidden} more`); return [header, ...shown].join("\n"); } function describeResourceContent(content: PiResourceContent): string { if (content.type === "image") { return `[image ${sanitizeTerminalText(content.mimeType, { maxLength: 60 })}, ${content.data.length} base64 chars]`; } return content.text; } function formatResourcePreview(serverName: string, uri: string, content: PiResourceContent[]): string { const header = `pi-mcp: ${sanitizeTerminalText(serverName, { maxLength: 64 })} — ${sanitizeTerminalText(uri, { maxLength: RESOURCE_FIELD_MAX_LENGTH })}`; const body = sanitizeTerminalText(content.map(describeResourceContent).join("\n"), { maxLength: 1200 }); // WHY: a notification is a single terminal line, so server-controlled resource content is // sanitized and bounded before it reaches the UI; an empty text payload renders the same // placeholder as an empty contents array instead of a trailing blank line. return `${header}\n${body || "(empty resource)"}`; } /** Row for `/mcp:resources` with no server argument: resource and template counts per server. */ async function describeServerResources(server: ManagedServer, deps: RegisterCommandsDeps): Promise<string> { const label = sanitizeTerminalText(server.name, { maxLength: 64 }); const client = server.client; if (!client || !supportsResources(client)) return ` ${label} — no MCP resources`; try { const catalog = await deps.resources.getCatalog(server.name, client, { requestTimeoutMs: deps.manager.resolveRequestTimeoutMs(server), }); return ` ${label} — ${catalog.resources.length} resource(s), ${catalog.templates.length} template(s)`; } catch (err) { return ` ${label} — failed: ${formatErrorMessage(err)}`; } } async function notifyResourceSummary(deps: RegisterCommandsDeps, ctx: ExtensionCommandContext): Promise<void> { const connected = deps.manager.getAllServers().filter((server) => server.state === "ready"); if (connected.length === 0) { ctx.ui.notify("pi-mcp: No connected MCP servers. Use /mcp:start <name> to connect one.", "info"); return; } const rows = await Promise.all(connected.map((server) => describeServerResources(server, deps))); ctx.ui.notify(["pi-mcp: MCP resources", ...rows].join("\n"), "info"); } /** Resolve `/mcp:resources <server>` to a live client, notifying the user when it cannot. */ function resolveResourceTarget( serverName: string, ctx: ExtensionCommandContext, deps: RegisterCommandsDeps, ): ResourceTarget | null { const label = sanitizeTerminalText(serverName, { maxLength: 64 }); const server = deps.manager.getServer(serverName); if (!server) { ctx.ui.notify(`pi-mcp: No server named "${label}"`, "error"); return null; } const client = server.client; if (!client || server.state !== "ready") { ctx.ui.notify(`pi-mcp: ${label} is not connected. Run /mcp:start ${label}.`, "error"); return null; } if (!supportsResources(client)) { ctx.ui.notify(`pi-mcp: ${label} does not expose MCP resources.`, "info"); return null; } return { server, client }; } async function notifyResourceList( target: ResourceTarget, deps: RegisterCommandsDeps, ctx: ExtensionCommandContext, ): Promise<void> { try { const catalog = await deps.resources.getCatalog(target.server.name, target.client, { requestTimeoutMs: deps.manager.resolveRequestTimeoutMs(target.server), }); ctx.ui.notify(formatResourceCatalog(target.server.name, catalog), "info"); } catch (err) { const label = sanitizeTerminalText(target.server.name, { maxLength: 64 }); ctx.ui.notify(`pi-mcp: Failed to list resources for ${label} — ${formatErrorMessage(err)}`, "error"); } } async function notifyResourceContent( target: ResourceTarget, uri: string, deps: RegisterCommandsDeps, ctx: ExtensionCommandContext, ): Promise<void> { try { const content = await deps.resources.readResource(target.server.name, target.client, uri, { requestTimeoutMs: deps.manager.resolveRequestTimeoutMs(target.server), }); ctx.ui.notify(formatResourcePreview(target.server.name, uri, content), "info"); } catch (err) { const label = sanitizeTerminalText(uri, { maxLength: RESOURCE_FIELD_MAX_LENGTH }); ctx.ui.notify(`pi-mcp: Failed to read ${label} — ${formatErrorMessage(err)}`, "error"); } } async function handleMcpResources( args: string, ctx: ExtensionCommandContext, deps: RegisterCommandsDeps, ): Promise<void> { const serverNames = deps.manager.getAllServers().map((server) => server.name); const { server: serverName, uri } = parseResourceArguments(args, serverNames); if (!serverName) return notifyResourceSummary(deps, ctx); const target = resolveResourceTarget(serverName, ctx, deps); if (!target) return; if (uri) return notifyResourceContent(target, uri, deps, ctx); return notifyResourceList(target, deps, ctx); } export function registerCommands(pi: ExtensionAPI, deps: RegisterCommandsDeps): void { const { manager } = deps; // ── 4. /mcp — show server status ───────────────────────────────────────── pi.registerCommand("mcp", { description: "Show MCP server status. Usage: /mcp [server-name] for detail.", handler: async (args, ctx) => { await handleMcpStatus(args, ctx, deps); }, getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()), }); // ── 5. /mcp:stop — stop a server ───────────────────────────────────────── pi.registerCommand("mcp:stop", { description: "Stop an MCP server. Usage: /mcp:stop <server-name>", handler: (args, ctx) => handleMcpStop(args, ctx, deps), getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()), }); // ── 6. /mcp:start — manually start a lazy server ───────────────────────── pi.registerCommand("mcp:start", { description: "Start an MCP server. Usage: /mcp:start <server-name>", handler: (args, ctx) => handleMcpStart(args, ctx, deps), getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()), }); pi.registerCommand("mcp:auth", { description: "Connect with OAuth; use /mcp:auth <name> --reset to discard stored credentials.", handler: (args, ctx) => handleMcpAuth(args, ctx, deps), getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers(), { authOnly: true }), }); // ── 7. /mcp:resources — list or read MCP resources ─────────────────────── pi.registerCommand("mcp:resources", { description: "List MCP resources. Usage: /mcp:resources [server] [uri] to read one resource.", handler: (args, ctx) => handleMcpResources(args, ctx, deps), getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()), }); }