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.

438 lines (391 loc) • 17.3 kB
/** * MCP resource bridge for pi-mcp. * * Bridges server-exposed resources and resource templates into Pi: * - Paginated `resources/list` and `resources/templates/list` (cursor loop, max-page guard) * - On-demand `resources/read` with text/image/binary conversion * - A per-server catalog cache invalidated by `notifications/resources/list_changed` * - AbortSignal, request timeouts, and OAuth 401/403 routing */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { ListResourcesResultSchema, ListResourceTemplatesResultSchema, ReadResourceResultSchema, type ListResourcesResult, type ListResourceTemplatesResult, type ReadResourceResult, } from "@modelcontextprotocol/sdk/types.js"; import { AuthCancelledError, McpError } from "./errors.js"; import { isCancellationError, isOAuthUnauthorizedError, isSdkTimeoutError } from "./connect-policy.js"; import { reportDiagnostic } from "./debug-log.js"; import { redactSecrets } from "./secret-redaction.js"; import { sanitizeTerminalText } from "./terminal-text.js"; // ─── Types ──────────────────────────────────────────────────────────────────── /** One `resources/list` entry, reduced to the fields Pi can act on. */ export interface McpResourceDefinition { uri: string; name: string; description?: string | undefined; mimeType?: string | undefined; } /** One `resources/templates/list` entry. */ export interface McpResourceTemplateDefinition { uriTemplate: string; name: string; description?: string | undefined; mimeType?: string | undefined; } /** A server's advertised resources plus its URI templates. */ export interface ResourceCatalog { resources: McpResourceDefinition[]; templates: McpResourceTemplateDefinition[]; } export interface ResourceRequestOptions { requestTimeoutMs: number; signal?: AbortSignal | undefined; } /** Pi content block produced from one `resources/read` content entry. */ export type PiResourceContent = | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; /** Upper bound on pages per list method, so a malfunctioning server cannot page us forever. */ const MAX_PAGES = 100; export interface ResourcePage<T> { items: T[]; nextCursor: string | undefined; } // ─── Pagination & method-not-found degradation ──────────────────────────────── function reportPaginationLimit(serverName: string, method: string): void { reportDiagnostic({ level: "warn", event: "resources.pagination_limit", server: serverName, message: `${method} pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`, fields: { pages: MAX_PAGES }, }); } /** * True for a JSON-RPC `-32601 Method not found` response. * WHY: A server may advertise the `resources` capability and still not implement * `resources/templates/list` (templates are optional in practice). That answer means * "this server has no templates", not "the catalog failed". */ function isMethodNotFoundError(err: unknown): boolean { if (typeof err !== "object" || err === null || !("code" in err)) return false; return (err as { code?: unknown }).code === -32601; } /** * Race an in-flight resource request against an abort signal. * Abort wins as {@link AuthCancelledError}; the in-flight request is left to * settle on its own (its rejection is suppressed). */ async function raceWithAbort<T>( callPromise: Promise<T>, signal: AbortSignal | undefined, serverName: string, ): Promise<T> { if (!signal) return callPromise; callPromise.catch(() => { // WHY: Suppress unhandled rejection if in-flight request rejects after abort wins the race. }); let onAbort!: () => void; const abortPromise = new Promise<never>((_resolve, reject) => { onAbort = () => reject(new AuthCancelledError(serverName)); signal.addEventListener("abort", onAbort, { once: true }); }); try { return await Promise.race([callPromise, abortPromise]); } finally { signal.removeEventListener("abort", onAbort); } } /** * Follow a cursor across pages until the server stops sending one, degrading a JSON-RPC * `-32601 Method not found` response to an empty list. * Aborts surface as {@link AuthCancelledError}; every other failure reaches the caller. */ export async function collectPages<T>( serverName: string, method: string, signal: AbortSignal | undefined, fetchPage: (cursor: string | undefined) => Promise<ResourcePage<T>>, ): Promise<T[]> { try { return await collectCursorPages(serverName, method, signal, fetchPage); } catch (err) { if (isCancellationError(err, signal)) { throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName); } if (isMethodNotFoundError(err)) return []; throw err; } } async function collectCursorPages<T>( serverName: string, method: string, signal: AbortSignal | undefined, fetchPage: (cursor: string | undefined) => Promise<ResourcePage<T>>, ): Promise<T[]> { const items: T[] = []; let cursor: string | undefined; for (let page = 0; page < MAX_PAGES; page++) { if (signal?.aborted) throw new AuthCancelledError(serverName); const result = await raceWithAbort(fetchPage(cursor), signal, serverName); items.push(...result.items); // WHY: The spec ends pagination by omitting `nextCursor`; an empty string means the same // thing and some servers send it, so neither may win another round trip. cursor = result.nextCursor; if (!cursor) return items; } reportPaginationLimit(serverName, method); return items; } // ─── Typed requests ─────────────────────────────────────────────────────────── function requestOptionsFor(options: ResourceRequestOptions): { timeout: number; signal?: AbortSignal } { return { timeout: options.requestTimeoutMs, ...(options.signal ? { signal: options.signal } : {}), }; } function cursorParams(cursor: string | undefined): { cursor: string } | undefined { return cursor ? { cursor } : undefined; } /** Fetch one `resources/list` page through the typed helper, falling back to a raw request. */ function fetchResourcesPage( client: Client, cursor: string | undefined, options: ResourceRequestOptions, ): Promise<ListResourcesResult> { const reqOpts = requestOptionsFor(options); const params = cursorParams(cursor); if (typeof client.listResources === "function") return client.listResources(params, reqOpts); return client.request({ method: "resources/list", params: params ?? {} }, ListResourcesResultSchema, reqOpts); } /** Fetch one `resources/templates/list` page. */ function fetchResourceTemplatesPage( client: Client, cursor: string | undefined, options: ResourceRequestOptions, ): Promise<ListResourceTemplatesResult> { const reqOpts = requestOptionsFor(options); const params = cursorParams(cursor); if (typeof client.listResourceTemplates === "function") return client.listResourceTemplates(params, reqOpts); return client.request( { method: "resources/templates/list", params: params ?? {} }, ListResourceTemplatesResultSchema, reqOpts, ); } function readResourceOnce(client: Client, uri: string, options: ResourceRequestOptions): Promise<ReadResourceResult> { const requestOptions = requestOptionsFor(options); if (typeof client.readResource === "function") { return client.readResource({ uri }, requestOptions); } return client.request({ method: "resources/read", params: { uri } }, ReadResourceResultSchema, requestOptions); } // ─── Discovery ──────────────────────────────────────────────────────────────── export async function listAllResources( client: Client, serverName: string, options: ResourceRequestOptions, ): Promise<McpResourceDefinition[]> { return collectPages(serverName, "resources/list", options.signal, async (cursor) => { const result = await fetchResourcesPage(client, cursor, options); return { items: result.resources.map(toResourceDefinition), nextCursor: result.nextCursor }; }); } export async function listAllResourceTemplates( client: Client, serverName: string, options: ResourceRequestOptions, ): Promise<McpResourceTemplateDefinition[]> { return collectPages(serverName, "resources/templates/list", options.signal, async (cursor) => { const result = await fetchResourceTemplatesPage(client, cursor, options); return { items: result.resourceTemplates.map(toResourceTemplateDefinition), nextCursor: result.nextCursor }; }); } function toResourceDefinition(resource: ListResourcesResult["resources"][number]): McpResourceDefinition { return { uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, }; } function toResourceTemplateDefinition( template: ListResourceTemplatesResult["resourceTemplates"][number], ): McpResourceTemplateDefinition { return { uriTemplate: template.uriTemplate, name: template.name, description: template.description, mimeType: template.mimeType, }; } // ─── Content conversion ─────────────────────────────────────────────────────── /** * Convert `resources/read` contents into Pi content blocks. * * WHY: Inline image blobs are what the model can actually consume; every other binary payload * becomes a bounded summary so a large archive cannot flood the context window. */ export function convertResourceContents(contents: readonly unknown[]): PiResourceContent[] { return contents.map(convertResourceContent); } function resolveResourceUri(raw: unknown): string { const trimmed = typeof raw === "string" ? raw.trim() : ""; return trimmed.length > 0 ? trimmed : "unknown"; } export function convertResourceContent(item: unknown): PiResourceContent { if (typeof item !== "object" || item === null) { return { type: "text", text: String(item) }; } const entry = item as Record<string, unknown>; // WHY: Check `typeof text === "string"` rather than truthiness so an empty text resource // (`text: ""`) stays empty instead of degrading to a `[Resource: uri]` placeholder. if (typeof entry["text"] === "string") { return { type: "text", text: entry["text"] }; } if (typeof entry["blob"] === "string") { return convertBlobResource(entry); } return { type: "text", text: `[Resource: ${resolveResourceUri(entry["uri"])}]` }; } function resolveBlobMimeType(raw: unknown): string { const trimmed = typeof raw === "string" ? raw.trim() : ""; return trimmed.length > 0 ? trimmed : "application/octet-stream"; } function convertBlobResource(entry: Record<string, unknown>): PiResourceContent { const uri = resolveResourceUri(entry["uri"]); const mimeType = resolveBlobMimeType(entry["mimeType"]); const blob = entry["blob"] as string; if (mimeType.startsWith("image/")) { return { type: "image", data: blob, mimeType }; } return { type: "text", text: `[Binary resource: ${uri} (${mimeType}, ${Buffer.byteLength(blob, "base64")} bytes)]` }; } // ─── Resource bridge ────────────────────────────────────────────────────────── interface CachedCatalog { generation: number; catalog: ResourceCatalog; } function cloneCatalog(catalog: ResourceCatalog): ResourceCatalog { return { resources: [...catalog.resources], templates: [...catalog.templates] }; } function formatFailureDetail(err: unknown): string { const raw = err instanceof Error ? err.message : String((err as { message?: unknown } | null)?.message ?? err); return redactSecrets(sanitizeTerminalText(raw)); } /** * Caches each server's resource catalog and serves `resources/read` on demand. * * The catalog is fetched lazily (the first `mcp_list_resources` call for a server) and kept * until the server reports `notifications/resources/list_changed`, which bumps a monotonic * generation. A page set that was in flight when the notification arrived is discarded rather * than cached, because the server has already declared it stale. */ export class ResourceBridge { private readonly catalogs = new Map<string, CachedCatalog>(); private readonly generations = new Map<string, number>(); private onAuthError?: ((serverName: string, error: unknown) => Promise<void> | void) | undefined; /** Register callback invoked when a `resources/*` request fails with 401/403. */ setAuthErrorCallback(cb: (serverName: string, error: unknown) => Promise<void> | void): void { this.onAuthError = cb; } /** Drop a server's cached catalog after `notifications/resources/list_changed`. */ invalidate(serverName: string): void { this.generations.set(serverName, this._generation(serverName) + 1); this.catalogs.delete(serverName); } /** Forget a server's cached catalog when it stops or leaves the config. */ forget(serverName: string): void { // WHY: advance the generation instead of deleting it. A page set that was in flight when // the server stopped would otherwise be stamped with the same generation a restarted // server reads, resurrecting a catalog fetched from the previous client. this.generations.set(serverName, this._generation(serverName) + 1); this.catalogs.delete(serverName); } /** Return the server's resources and templates, fetching the catalog on first use. */ async getCatalog( serverName: string, client: Client, options: ResourceRequestOptions, ): Promise<ResourceCatalog> { if (options.signal?.aborted) throw new AuthCancelledError(serverName); const generation = this._generation(serverName); const cached = this.catalogs.get(serverName); if (cached?.generation === generation) { return cloneCatalog(cached.catalog); } const catalog = await this._fetchCatalog(serverName, client, options); // WHY: only cache when the generation has not advanced while these pages were in flight; // otherwise a slow stale response would overwrite a newer generation's catalog or // repopulate a server that was forgotten while in flight. if (this._generation(serverName) === generation) { this.catalogs.set(serverName, { generation, catalog }); } return cloneCatalog(catalog); } /** Read one resource and convert its contents into Pi content blocks. */ async readResource( serverName: string, client: Client, uri: string, options: ResourceRequestOptions, ): Promise<PiResourceContent[]> { if (options.signal?.aborted) throw new AuthCancelledError(serverName); try { const result = await raceWithAbort( readResourceOnce(client, uri, options), options.signal, serverName, ); return convertResourceContents(result.contents); } catch (err) { return await this._rethrowFailure(serverName, err, options.signal); } } // ─── Internal ─────────────────────────────────────────────────────────────── private _generation(serverName: string): number { return this.generations.get(serverName) ?? 0; } private async _fetchCatalog( serverName: string, client: Client, options: ResourceRequestOptions, ): Promise<ResourceCatalog> { try { const resources = await listAllResources(client, serverName, options); const templates = await listAllResourceTemplates(client, serverName, options); return { resources, templates }; } catch (err) { return await this._rethrowFailure(serverName, err, options.signal); } } /** * Normalize a `resources/*` failure after notifying the OAuth handler when the server needs * authorization: cancellation stays a cancellation, an SDK request timeout keeps `"timeout"`, * and everything else is a protocol failure. */ private async _rethrowFailure( serverName: string, err: unknown, signal: AbortSignal | undefined, ): Promise<never> { // WHY: an abort mid-request is authoritative and must be recognized before the protocol // mapping — the SDK rejects an in-flight abort with its own RequestTimeout error. if (isCancellationError(err, signal)) { throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName); } if (isOAuthUnauthorizedError(err)) { await this.onAuthError?.(serverName, err); } throw new McpError( `MCP resource request failed for ${serverName}: ${formatFailureDetail(err)}`, serverName, isSdkTimeoutError(err) ? "timeout" : "protocol", err, ); } }