UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

447 lines • 22.5 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * MCP Server for withPICA * Enables AI assistants to interact with withPICA via the Model Context Protocol */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { createServer } from "node:http"; import { timingSafeEqual, randomUUID } from "node:crypto"; import { PicaClient } from "@withpica/mcp-sdk"; import { ToolRegistry } from "./tools/index.js"; import { ResourceRegistry } from "./resources/index.js"; import { PromptRegistry } from "./prompts/index.js"; import { SKILLS_EXTENSION_CAPABILITY, SkillsGetRequestSchema, SkillsListRequestSchema, skillsGetResult, skillsListResult, } from "./skills/index.js"; import { logError } from "@withpica/mcp-utils"; import { makeStdioCallerContext, makeHttpAuditFn, makeHttpInteractionFn, McpAuditLogger, } from "@withpica/mcp-utils"; import { SERVER_INSTRUCTIONS_AUTHENTICATED, SERVER_INSTRUCTIONS_UNAUTHENTICATED, } from "./server-instructions.js"; export class PicaMcpServer { server; pica; toolRegistry; resourceRegistry; promptRegistry; config; httpServer; // ADR-226 Phase 4 PR-B.5 — session UUID generated at process start and // carried through every tool call's interaction relay. Decision 8 source // rule for stdio: UUID generated at server initialize for the process // lifetime. sessionId = randomUUID(); constructor(config) { this.config = config; // Initialize PICA SDK client (null in lobby mode) if (config.picaApiKey) { this.pica = new PicaClient({ apiKey: config.picaApiKey, baseUrl: config.picaApiUrl, debug: config.debug, }); } else { this.pica = null; } // Initialize MCP server // ADR-211 Phase 1 Primitive C: select the lobby variant of // server-instructions when the binary boots without a connection key. // The authenticated variant covers 300+ catalog tools; the lobby // variant points at the 3 lobby tools (pica_signup_start + // pica_sign_in + pica_sign_out) since none of the catalog surface // is reachable yet. const handshakeInstructions = config.lobbyMode ? SERVER_INSTRUCTIONS_UNAUTHENTICATED : SERVER_INSTRUCTIONS_AUTHENTICATED; this.server = new Server({ name: config.serverName, version: config.version, }, { capabilities: { // listChanged: true on tools + resources tells the client we // may emit `notifications/tools/list_changed` and // `notifications/resources/list_changed` after sign-in or // sign-out hot-swaps the registries. Without this declaration // the client won't subscribe to those notifications, and the // tool surface stays frozen at the lobby pair forever. tools: { listChanged: true }, resources: { listChanged: true }, prompts: {}, // ADR-328 — SEP-2640 skills extension. Declared in lobby mode too // (capabilities are fixed at initialize and sign-in hot-swaps the // registries); skills/list is empty until signed in. extensions: SKILLS_EXTENSION_CAPABILITY, }, instructions: handshakeInstructions, }); // Initialize registries with stdio caller context (api key hash) this.toolRegistry = new ToolRegistry(this.pica, config, (apiKey) => this.reinitialize(apiKey), makeStdioCallerContext(config.picaApiKey), () => this.signOut()); this.resourceRegistry = new ResourceRegistry(this.pica, config); this.promptRegistry = new PromptRegistry(this.pica ?? undefined); // Wire audit logger — fire-and-forget POST to /api/mcp/audit AND // /api/mcp/internal/log-interaction. ADR-226 Phase 4 PR-B.5: every // tool call (read + write) writes one assistant_interactions row + // bumps mcp_sessions; the original audit endpoint stays write-tool-only. this.toolRegistry.setAuditLogger(this.buildAuditLogger(config.picaApiKey, config.picaApiUrl)); this.setupHandlers(); } /** * Construct the dual-relay McpAuditLogger. * * One callback, two policies: * - mcp_audit_log (via /api/mcp/audit): write-tool only — preserves * ADR-154 F5's audit-log shape unchanged. * - assistant_interactions + mcp_sessions (via * /api/mcp/internal/log-interaction): EVERY tool call — * ADR-226 Decision 8. * * `logReads: true` is mandatory: the McpAuditLogger short-circuits on * `risk_level === "safe"` by default, which would block read-tool * calls from reaching the substrate relay. The mcp_audit_log endpoint * itself stays write-tool-only via a `risk_level !== "safe"` gate * inside the callback. */ buildAuditLogger(apiKey, baseUrl) { const interactionCtx = { sessionId: this.sessionId, // Stdio doesn't have access to clientInfo at construction time — // the SDK's `getClientVersion()` only returns after `initialize` // has been received. For now we send nulls; the relay endpoint // upserts mcp_sessions with NULL agent identity, and an enrichment // pass after `oninitialized` could update later. clientName: null, clientVersion: null, // user_agent on stdio is "stdio:<pid>" — distinct from HTTP's // browser-style UA but useful for grouping per-process activity. userAgent: `stdio:${process.pid}`, }; const auditFn = makeHttpAuditFn({ baseUrl, apiKey }); const interactionFn = makeHttpInteractionFn({ baseUrl, apiKey, context: interactionCtx, }); return new McpAuditLogger(async (entry) => { // Both fire-and-forget; failures swallowed by Promise.allSettled + // McpAuditLogger's own try/catch wrapper. Telemetry must not break // tool execution. await Promise.allSettled([ // mcp_audit_log: write tools only (preserves ADR-154 F5 behaviour). entry.risk_level !== "safe" ? auditFn(entry) : Promise.resolve(), // assistant_interactions: EVERY tool call (ADR-226 Decision 8). interactionFn(entry), ]); }, /* logReads */ true); } /** * Reinitialize server with new credentials (called after successful auth) */ reinitialize(apiKey) { this.pica = new PicaClient({ apiKey, baseUrl: this.config.picaApiUrl, debug: this.config.debug, }); this.config = { ...this.config, picaApiKey: apiKey, lobbyMode: false }; this.toolRegistry = new ToolRegistry(this.pica, this.config, (key) => this.reinitialize(key), makeStdioCallerContext(apiKey), () => this.signOut()); this.toolRegistry.setAuditLogger(this.buildAuditLogger(apiKey, this.config.picaApiUrl)); this.resourceRegistry = new ResourceRegistry(this.pica, this.config); console.error("[MCP Server] Reinitialized with new credentials"); // Tell the client the tool + resource lists have changed so it // re-fetches and discovers the full authenticated surface. Without // these notifications the client keeps showing the lobby pair // (sign_in/sign_out) even though sign-in succeeded — the in-memory // registries on the server know about the 168 catalog tools but // the client never asks for them again. // // Fire-and-forget: a failure to notify is a soft error (the user // can manually trigger another tool call which will work since the // server's internal state is correct), so we log and continue // rather than throwing inside the callback path that just rebuilt // the registries. this.server.sendToolListChanged().catch((err) => { console.error("[MCP Server] Failed to send tools/list_changed notification:", err); }); this.server.sendResourceListChanged().catch((err) => { console.error("[MCP Server] Failed to send resources/list_changed notification:", err); }); } /** * Reset the server back to lobby mode (called after sign_out). * Mirrors reinitialize() but in reverse: clears the PicaClient, * sets lobbyMode=true, and rebuilds the tool/resource registries so * the next tools/list call returns only [pica_sign_in, pica_sign_out]. * * Without this the server would keep advertising the full authenticated * tool surface after sign_out until the process is restarted, which * lies about what's actually available — every privileged tool call * would still fail at the API layer (no PicaClient), but the surface * itself shouldn't pretend the catalog is reachable. */ signOut() { this.pica = null; this.config = { ...this.config, picaApiKey: "", lobbyMode: true }; this.toolRegistry = new ToolRegistry(this.pica, this.config, (key) => this.reinitialize(key), makeStdioCallerContext(""), () => this.signOut()); this.toolRegistry.setAuditLogger(this.buildAuditLogger("", this.config.picaApiUrl)); this.resourceRegistry = new ResourceRegistry(this.pica, this.config); console.error("[MCP Server] Reset to lobby mode after sign_out"); // Same listChanged story as reinitialize() — without these the // client keeps advertising the full authenticated surface to the // model after sign-out, and any tool call would fail at the API // layer with a confusing "no credentials" error instead of the // model saying "you need to sign in first." this.server.sendToolListChanged().catch((err) => { console.error("[MCP Server] Failed to send tools/list_changed notification (signOut):", err); }); this.server.sendResourceListChanged().catch((err) => { console.error("[MCP Server] Failed to send resources/list_changed notification (signOut):", err); }); } /** * Set up MCP protocol handlers on the main server instance (for stdio) */ setupHandlers() { this.registerHandlers(this.server); } /** * Start the MCP server. * * Transport is selected by environment: * - MCP_TRANSPORT=http → HTTP server on MCP_PORT (default 3100) * - Otherwise → stdio (default, for Claude Code / Cursor / VS Code) */ async start() { // ADR-226 Phase 6 — vocabulary context-prepend at handshake. // Mutate the SDK's `_instructions` field BEFORE `connect(transport)` // so the InitializeResult sent to the client includes the user's // vocabulary preferences appended to the base ADR-208 instructions. // // This is a controlled coupling to the SDK's private `_instructions` // field declared in // node_modules/@modelcontextprotocol/sdk/dist/cjs/server/index.d.ts:78 // The field has been stable since SDK 1.0; the SDK is pinned in // package.json so a future minor bump won't silently break this. // A unit test at __tests__/server-vocabulary-injection.test.ts // catches a rename within one CI cycle. // // Gated on `this.pica && !lobbyMode` because vocabulary is per-user // and only stdio (one process = one user) maps cleanly to a single // static instructions string. HTTP / lobby skip the mutation; their // initialize-time instructions stay as the static catalog version. await this.injectVocabularyInstructions(); const mode = process.env.MCP_TRANSPORT; if (mode === "http") { await this.startHttp(); } else { await this.startStdio(); } console.error("[MCP Server] PICA MCP Server started"); console.error(`[MCP Server] Version: ${this.config.version}`); console.error(`[MCP Server] Transport: ${mode === "http" ? "HTTP" : "stdio"}`); console.error(`[MCP Server] Mode: ${this.config.lobbyMode ? "lobby (sign in to connect)" : "authenticated"}`); console.error(`[MCP Server] API URL: ${this.config.picaApiUrl}`); console.error("[MCP Server] Ready to accept connections"); } /** * ADR-226 Phase 6 — fetch the user's vocabulary and append it to the * SDK Server's static instructions string. Bails silently on lobby * mode (no apiKey-to-user mapping yet) or any fetch failure (the base * instructions are still valid; vocabulary is a layer on top, not a * gate). Runs at most once per server instance. */ async injectVocabularyInstructions() { if (!this.pica || this.config.lobbyMode) return; try { const result = await this.pica.assistantInteractions.getVocabularyContext(); const vocab = result?.instructions; if (!vocab) return; const server = this.server; const base = server._instructions ?? ""; server._instructions = base ? `${base}\n\n${vocab}` : vocab; } catch (err) { // Vocabulary injection is best-effort — never block boot on it. // Logged at warn so a transient network blip doesn't generate a // Sentry error event per feedback_auth_handler_log_level.md. const cause = err instanceof Error ? err.message : String(err); console.error(`[MCP Server] Vocabulary injection failed (continuing with base instructions): ${cause}`); } } async startStdio() { const transport = new StdioServerTransport(); await this.server.connect(transport); } async startHttp() { const port = parseInt(process.env.MCP_PORT || "3100", 10); // Each HTTP request gets its own transport instance (stateless sessions) const httpServer = createServer(async (req, res) => { // CORS headers for browser-based clients res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id"); res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } // Auth check — require MCP_HTTP_SECRET or PICA_API_KEY as Bearer token const httpSecret = process.env.MCP_HTTP_SECRET || process.env.PICA_API_KEY; if (httpSecret) { const authHeader = req.headers["authorization"] ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; const tokenBuf = Buffer.from(token); const secretBuf = Buffer.from(httpSecret); const isValid = tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf); if (!isValid) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Unauthorized" })); return; } } // Health check if (req.method === "GET" && req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", server: this.config.serverName, version: this.config.version, tools: this.toolRegistry.listTools().length, })); return; } // MCP endpoint — create per-request transport if (req.url === "/mcp" || req.url === "/") { try { // Read body const chunks = []; for await (const chunk of req) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } const body = Buffer.concat(chunks).toString("utf-8"); const parsedBody = body ? JSON.parse(body) : undefined; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless }); // Connect a fresh server instance for this request const requestServer = new Server({ name: this.config.serverName, version: this.config.version, }, { capabilities: { tools: {}, resources: {}, prompts: {}, extensions: SKILLS_EXTENSION_CAPABILITY, }, }); // Re-register handlers on the request-scoped server this.registerHandlers(requestServer); await requestServer.connect(transport); await transport.handleRequest(req, res, parsedBody); } catch (err) { if (this.config.debug) { console.error("[MCP Server] HTTP request error:", err); } if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Internal server error" })); } } return; } // 404 res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }); this.httpServer = httpServer; await new Promise((resolve) => { httpServer.listen(port, () => { console.error(`[MCP Server] HTTP listening on port ${port}`); console.error(`[MCP Server] Endpoint: http://localhost:${port}/mcp`); console.error(`[MCP Server] Health: http://localhost:${port}/health`); resolve(); }); }); } /** * Register protocol handlers on a server instance. * Used by both the main server (stdio) and per-request servers (HTTP). */ registerHandlers(server) { server.setRequestHandler(ListToolsRequestSchema, async () => ({ // ADR-248 — stdio surface is the derived view `audiences ∋ "stdio"`. // Every customer tool currently declares "stdio" (advertise-all), so // this preserves today's behaviour while making the surface a pure // function of declared metadata rather than "lists everything". tools: this.toolRegistry .listTools() .filter((t) => t.audiences?.includes("stdio")), })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args, _meta } = request.params; const toolArgs = _meta ? { ...(args || {}), _meta } : args || {}; try { // ADR-200 Phase 1: pass the per-request `Server` so executors can // call `server.elicitInput()` / `server.getClientCapabilities()` // when issuing an elicitation/create request. Each per-request // HTTP server is a different instance; positional context // avoids registry-level state that would race across calls. return await this.toolRegistry.executeTool(name, toolArgs, { server }); } catch (error) { logError(`Tool execution: ${name}`, error); throw error; } }); server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: this.resourceRegistry.listResources(), })); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; try { return await this.resourceRegistry.readResource(uri); } catch (error) { logError(`Resource read: ${uri}`, error); throw error; } }); // ADR-328 — SEP-2640 skills/list + skills/get. server.setRequestHandler(SkillsListRequestSchema, async () => skillsListResult(this.resourceRegistry.listSkillEntries())); server.setRequestHandler(SkillsGetRequestSchema, async (request) => skillsGetResult(request.params?.uri, (uri) => this.resourceRegistry.getSkillEntry(uri))); server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: this.promptRegistry.listPrompts(), })); server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { return await this.promptRegistry.getPrompt(name, args); } catch (error) { logError(`Prompt get: ${name}`, error); throw error; } }); } /** * Stop the MCP server */ async stop() { if (this.httpServer) { await new Promise((resolve) => { this.httpServer.close(() => resolve()); }); } await this.server.close(); console.error("[MCP Server] Server stopped"); } } //# sourceMappingURL=server.js.map