UNPKG

@withpica/mcp-server

Version:

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

234 lines • 10.2 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { writeCredentials, clearCredentials, } from "@withpica/mcp-utils"; const POLL_INTERVAL_MS = 3000; const POLL_TIMEOUT_MS = 5 * 60 * 1000; export class AuthTools { config; reinitialize; signOutCallback; constructor(config, reinitialize, signOutCallback = () => { }) { this.config = config; this.reinitialize = reinitialize; this.signOutCallback = signOutCallback; } getTools() { return [ { definition: { name: "pica_sign_in", scope: "always", annotations: { title: "sign in to PICA" }, // stdio-only: on HTTP the credential file write lands on Vercel's // ephemeral filesystem and never reaches the agent's connector — // the tool "succeeds" but accomplishes nothing (issue #558; the // 2026-05-23 lockout). HTTP callers connect via the OAuth // connector flow instead. audiences: ["stdio"], tier: "write", description: "sign in to PICA with your email — sends a magic link to your inbox. " + "use this to connect your music catalog. no API key needed.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { email: { type: "string", description: "your email address", }, }, required: ["email"], }, }, executor: this.signIn.bind(this), }, { definition: { name: "pica_sign_out", scope: "always", annotations: { title: "sign out of PICA" }, // stdio-only: on HTTP the credential file write lands on Vercel's // ephemeral filesystem and never reaches the agent's connector — // the tool "succeeds" but accomplishes nothing (issue #558; the // 2026-05-23 lockout). HTTP callers connect via the OAuth // connector flow instead. audiences: ["stdio"], tier: "write", description: "sign out of PICA — clears your saved credentials", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: {}, }, }, executor: this.signOut.bind(this), }, ]; } async signIn(args) { const email = args.email?.trim()?.toLowerCase(); if (!email || !email.includes("@") || !email.includes(".")) { return { content: [ { type: "text", text: "that doesn't look like an email address — please try again", }, ], isError: true, }; } const apiUrl = this.config.picaApiUrl.replace(/\/api$/, ""); const authorizeUrl = `${apiUrl}/api/public/mcp-auth/authorize`; const tokenUrl = `${apiUrl}/api/public/mcp-auth/token`; // Step 1: Request magic link let deviceCode; let interval; try { const res = await fetch(authorizeUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); if (res.status === 429) { return { content: [ { type: "text", text: "too many sign-in attempts — try again in an hour", }, ], isError: true, }; } if (!res.ok) { return { content: [ { type: "text", text: "failed to send magic link — please try again", }, ], isError: true, }; } const data = (await res.json()); deviceCode = data.device_code; interval = (data.interval || 3) * 1000; console.error(`[MCP Auth] Magic link sent to ${email}`); } catch { return { content: [ { type: "text", text: "couldn't reach PICA — check your internet connection and try again", }, ], isError: true, }; } // Step 2: Poll for token const startTime = Date.now(); while (Date.now() - startTime < POLL_TIMEOUT_MS) { await new Promise((resolve) => setTimeout(resolve, interval)); try { const res = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }), }); const data = (await res.json()); if (data.error === "authorization_pending") { continue; } if (data.error === "expired_token") { return { content: [ { type: "text", text: "magic link expired — say 'sign me in' to try again", }, ], isError: true, }; } if (data.access_token) { const credentials = { api_key: data.access_token, email, org_name: data.org_name || "my catalog", authenticated_at: new Date().toISOString(), }; writeCredentials(this.config.credentialsPath, credentials); this.reinitialize(data.access_token); console.error(`[MCP Auth] Signed in as ${email}`); if (data.is_new_user) { return { content: [ { type: "text", text: `welcome to PICA, ${email}. you're all signed in.\n\n` + `quick first thing — your workspace is currently named "My Organization" (the boring default). want to give it a real name? it'll appear on exports, share links, and anywhere else your catalog shows up. just tell me what to call it, or say "skip" to set it later.\n\n` + `after that, we can:\n` + `- add your first song manually\n` + `- import an existing catalog from a CSV\n` + `- connect Spotify and pull your discography automatically\n\n` + `what works for you?`, }, ], }; } return { content: [ { type: "text", text: `signed in as ${email} (${data.org_name || "my catalog"}).\n\n` + `all tools are now available. try "what needs attention?" to see your catalog status.`, }, ], }; } } catch { continue; } } return { content: [ { type: "text", text: "magic link expired — say 'sign me in' to try again", }, ], isError: true, }; } async signOut(_args) { clearCredentials(this.config.credentialsPath); // Reset the server back to lobby mode: clear PicaClient, rebuild // toolRegistry/resourceRegistry so the next tools/list call returns // only [pica_sign_in, pica_sign_out]. Without this the client keeps // seeing the full authenticated tool surface until the process is // restarted, which would let an "ex-signed-out" client still call // privileged tools (the calls would fail at the API layer, but the // surface area itself shouldn't lie about what's available). this.signOutCallback(); console.error("[MCP Auth] Signed out — credentials cleared, lobby restored"); return { content: [ { type: "text", text: "signed out of PICA. say 'sign me in' to reconnect.", }, ], }; } } //# sourceMappingURL=auth.js.map