@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
438 lines • 21.7 kB
JavaScript
// 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 { 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: {},
},
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: {},
},
});
// 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;
}
});
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