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.

279 lines (252 loc) • 10.3 kB
/** * Persisted OAuth state schemas, origin-binding helpers, and atomic file I/O. */ import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js"; import { readFile, writeFile, mkdir, unlink, chmod, rename } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { AuthRequiredError, McpError } from "./errors.js"; import { authDir, statePath } from "./auth-lock.js"; import { canonicalServerUrl, isResourceCompatibleWithServerUrl, urlsMatch, } from "./oauth-policy.js"; export { canonicalServerUrl, isResourceCompatibleWithServerUrl, urlsMatch }; // ─── Persistent State Types & Schema ────────────────────────────────────────── export interface StoredClientInfo { client_id: string; client_secret: string | undefined; client_id_issued_at: number | undefined; client_secret_expires_at: number | undefined; token_endpoint_auth_method: string | undefined; redirect_uris: string[] | undefined; issuer: string | undefined; } export interface StoredTokens { access_token: string; token_type: string | undefined; refresh_token: string | undefined; expires_in: number | undefined; scope: string | undefined; /** ISO timestamp when tokens were saved (for expiry estimation). */ saved_at: string | undefined; issuer: string | undefined; } export interface StoredState { serverUrl?: string | undefined; clientInfo: StoredClientInfo | undefined; tokens: StoredTokens | undefined; discoveryState: OAuthDiscoveryState | undefined; } // WHY: Credentials persisted before SDK 1.31 (GHSA-6qxp-vccf-f47h) have no `issuer` // property. We allow `issuer: z.string().optional()` so legacy state loads gracefully; // the SDK binds them to the discovered authorization server on first successful use. // Unknown keys are stripped by z.object, so explicit schema inclusion is required to // persist and return the SDK's issuer stamp. const StoredClientInfoSchema = z.object({ client_id: z.string(), client_secret: z.string().optional(), client_id_issued_at: z.number().optional(), client_secret_expires_at: z.number().optional(), token_endpoint_auth_method: z.string().optional(), redirect_uris: z.array(z.string()).optional(), issuer: z.string().optional(), }); const StoredTokensSchema = z.object({ access_token: z.string(), token_type: z.string().optional(), refresh_token: z.string().optional(), expires_in: z.number().optional(), scope: z.string().optional(), saved_at: z.string().optional(), issuer: z.string().optional(), }); export const StoredStateSchema = z.object({ serverUrl: z.string().optional(), clientInfo: StoredClientInfoSchema.optional(), tokens: StoredTokensSchema.optional(), discoveryState: z.record(z.string(), z.unknown()).optional(), }); // ─── File helpers ───────────────────────────────────────────────────────────── type ParsedStoredState = z.output<typeof StoredStateSchema>; function emptyStoredState(expectedServerUrl?: string): StoredState { return { serverUrl: expectedServerUrl ? canonicalServerUrl(expectedServerUrl) : undefined, clientInfo: undefined, tokens: undefined, discoveryState: undefined, }; } async function readStoredStateFile( path: string, serverName: string, ): Promise<ParsedStoredState | null> { let raw: string; try { raw = await readFile(path, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } try { const json: unknown = JSON.parse(raw); return StoredStateSchema.parse(json); } catch (err) { const backupPath = `${path}.corrupt-${Date.now()}`; await rename(path, backupPath); throw new McpError( `Corrupt OAuth state file for "${serverName}" backed up to ${backupPath}`, serverName, "config", err, ); } } function cachedDiscoveryResource(parsed: ParsedStoredState): unknown { const discoveryState = parsed.discoveryState as OAuthDiscoveryState | undefined; const metadata = discoveryState?.resourceMetadata as { resource?: unknown } | undefined; return metadata?.resource; } function hasCompatibleCachedResource(parsed: ParsedStoredState, expectedServerUrl: string): boolean { const resource = cachedDiscoveryResource(parsed); return typeof resource !== "string" || isResourceCompatibleWithServerUrl(resource, expectedServerUrl); } function shouldDiscardStoredState(parsed: ParsedStoredState, expectedServerUrl: string): boolean { // WHY: Bind OAuth state to the canonical server URL so a project config with the same // server name cannot inherit global Bearer tokens for a different origin/path. if (parsed.serverUrl !== undefined && !urlsMatch(parsed.serverUrl, expectedServerUrl)) { return true; } // Legacy state file without top-level serverUrl whose cached resourceMetadata belongs to another server URL. return parsed.serverUrl === undefined && !hasCompatibleCachedResource(parsed, expectedServerUrl); } function cachedDiscoveryStateFor( parsed: ParsedStoredState, expectedServerUrl: string, ): OAuthDiscoveryState | undefined { if (!hasCompatibleCachedResource(parsed, expectedServerUrl)) { // WHY: Discard mismatched discoveryState so the MCP SDK's selectResourceURL() // never throws "Protected resource ... does not match expected". return undefined; } return parsed.discoveryState as OAuthDiscoveryState | undefined; } function toStoredClientInfo(info: NonNullable<ParsedStoredState["clientInfo"]>): StoredClientInfo { return { client_id: info.client_id, client_secret: info.client_secret, client_id_issued_at: info.client_id_issued_at, client_secret_expires_at: info.client_secret_expires_at, token_endpoint_auth_method: info.token_endpoint_auth_method, redirect_uris: info.redirect_uris, issuer: info.issuer, }; } function toStoredTokens(tokens: NonNullable<ParsedStoredState["tokens"]>): StoredTokens { return { access_token: tokens.access_token, token_type: tokens.token_type, refresh_token: tokens.refresh_token, expires_in: tokens.expires_in, scope: tokens.scope, saved_at: tokens.saved_at, issuer: tokens.issuer, }; } function normalizeStoredState( parsed: ParsedStoredState, discoveryState: OAuthDiscoveryState | undefined, expectedServerUrl?: string, ): StoredState { return { serverUrl: parsed.serverUrl ?? (expectedServerUrl ? canonicalServerUrl(expectedServerUrl) : undefined), clientInfo: parsed.clientInfo ? toStoredClientInfo(parsed.clientInfo) : undefined, tokens: parsed.tokens ? toStoredTokens(parsed.tokens) : undefined, discoveryState, }; } export async function loadState( serverName: string, storageDir?: string, expectedServerUrl?: string, ): Promise<StoredState> { const path = statePath(serverName, storageDir); const parsed = await readStoredStateFile(path, serverName); if (parsed === null) return emptyStoredState(expectedServerUrl); if (expectedServerUrl === undefined) { return normalizeStoredState(parsed, parsed.discoveryState as OAuthDiscoveryState | undefined); } if (shouldDiscardStoredState(parsed, expectedServerUrl)) { return emptyStoredState(expectedServerUrl); } return normalizeStoredState(parsed, cachedDiscoveryStateFor(parsed, expectedServerUrl), expectedServerUrl); } export async function saveState( serverName: string, state: StoredState, storageDir?: string, isCancelled: () => boolean = () => false, ): Promise<void> { const directory = authDir(storageDir); await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700); // Only write defined fields (never write codeVerifier to shared state) const toWrite: Record<string, unknown> = {}; if (state.serverUrl !== undefined) toWrite.serverUrl = state.serverUrl; if (state.clientInfo !== undefined) toWrite.clientInfo = state.clientInfo; if (state.tokens !== undefined) toWrite.tokens = state.tokens; if (state.discoveryState !== undefined) toWrite.discoveryState = state.discoveryState; if (isCancelled()) throw new AuthRequiredError(serverName, "Authorization cancelled"); const path = statePath(serverName, storageDir); const tmpPath = `${path}.tmp-${randomUUID()}`; try { await writeFile(tmpPath, JSON.stringify(toWrite, null, 2), { encoding: "utf8", mode: 0o600 }); await chmod(tmpPath, 0o600); if (isCancelled()) { await unlink(tmpPath).catch(() => { // WHY: Best-effort cleanup of temporary state file when cancelled before rename. }); throw new AuthRequiredError(serverName, "Authorization cancelled"); } await rename(tmpPath, path); await chmod(path, 0o600); } catch (err) { await unlink(tmpPath).catch(() => { // WHY: Best-effort cleanup of temporary state file after write or rename failure. }); throw err; } } function readAndParseStoredStateSync(path: string): z.output<typeof StoredStateSchema> | undefined { try { const raw = readFileSync(path, "utf8"); const json: unknown = JSON.parse(raw); const parsed = StoredStateSchema.safeParse(json); return parsed.success ? parsed.data : undefined; } catch { // WHY: State file may not exist yet (ENOENT), directory may be inaccessible, or JSON is malformed; return undefined. return undefined; } } /** * Synchronously load stored DCR redirect URIs from disk if valid and origin-compatible. * Used by silent McpOAuthProvider to report registered redirect_uris synchronously. */ export function loadStoredRedirectUrisSync( serverName: string, storageDir?: string, serverUrl?: string, ): string[] | undefined { const data = readAndParseStoredStateSync(statePath(serverName, storageDir)); if (!data) return undefined; if (serverUrl !== undefined && shouldDiscardStoredState(data, serverUrl)) { return undefined; } const uris = data.clientInfo?.redirect_uris; if (Array.isArray(uris) && uris.length > 0) { return uris.map(String); } return undefined; }