UNPKG

@withpica/mcp-server

Version:

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

285 lines • 13.3 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * Signup Tools — `pica_signup_start` (ADR-211 Phase 1). * * The single MCP-discoverable signup verb. Available unauthenticated on * BOTH transports (stdio binary's lobby + HTTP `/api/mcp` anonymous mode) * per ADR-211 § Primitive A (amended 2026-04-30 to expose alongside * `pica_sign_in` / `pica_sign_out` rather than replace them in Phase 1). * * Mints nothing locally — every invocation calls the public backend * endpoint `POST /api/public/onboarding/signup-start`. The signing secret * (`ONBOARDING_TOKEN_SECRET`) lives only on the backend; customer- * distributed `npx @withpica/mcp-server` binaries can never see it. Same * pattern that `pica_sign_in` (auth.ts) uses to call the public * `/api/public/mcp-auth/authorize` magic-link endpoint. * * Returns the JWT-bound URL via: * - `resource_link` (universal floor — every MCP client can render this) * - capability-gated `elicitation/create url` (mode: "url"; for clients * advertising `capabilities.elicitation.url`, server-issued consent * prompt to open the link in-line). See ADR-200 + `pica_upload`. * * Construction takes the API base URL + transport + optional DCR * client_id. The stdio binary stamps `transport: "stdio"`; the HTTP * carve-out in `app/api/mcp/route.ts` stamps `transport: "http"` and * passes `client_id` from the OAuth-protected-resource discovery context * when one is present. */ import { randomUUID } from "node:crypto"; import { clientSupportsUrlElicitation } from "@withpica/mcp-utils"; const EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; export class SignupTools { apiUrl; transport; clientId; constructor(config) { if (!config.apiUrl) { throw new Error("SignupTools requires apiUrl"); } if (config.transport !== "stdio" && config.transport !== "http") { throw new Error(`SignupTools.transport must be "stdio" or "http" (got: ${config.transport})`); } if (config.transport === "stdio" && config.clientId) { // stdio has no DCR client identifier. Constructor enforces the // invariant locally so the public endpoint never sees a confused // pairing in the first place. throw new Error("clientId must be null for stdio transport"); } this.apiUrl = config.apiUrl.replace(/\/+$/, ""); this.transport = config.transport; this.clientId = config.clientId ?? null; } getTools() { return [ { definition: { name: "pica_signup_start", scope: "always", audiences: ["stdio", "lobby"], tier: "write", description: "Sign up for withPICA from inside the agent, without leaving the chat. " + "Use this when the user wants to create a new withPICA account / catalog " + "and you're talking to them through an unauthenticated MCP connection " + "(stdio without an API key, or HTTP without a Bearer token). The tool " + "mints a single-use signup link valid for 15 minutes — surface the URL " + "to the user as a clickable resource_link. The link opens a page that " + "verifies their email by magic-link, creates their organisation, starts " + "their free runway (a non-expiring usage allowance — no day-count trial), and " + "(for stdio) shows a connection key to paste " + "into Claude Desktop's MCP config or (for HTTP) resumes the OAuth flow. " + "For existing users, prefer pica_sign_in.", 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: "The user's email address. The signup link is sent to this " + "inbox; the user clicks it to verify ownership before any " + "account is created. Required.", }, org_name: { type: "string", description: "Optional human name for the user's catalog/organisation. " + "Defaults to '<email-localpart>'s catalog' if omitted. " + "Editable later in settings.", }, purpose: { type: "string", description: "Optional free-text — what the user is trying to do with " + "PICA, captured for product analytics (e.g. 'managing my " + "first album credits', 'exporting royalty statements'). " + "Not surfaced to the user.", }, referral_code: { type: "string", description: "Optional referral code from an existing withPICA user who " + "invited this person (their `?ref=` code, shown in Settings → " + "invite & earn). When supplied, the signup is attributed to " + "that referrer and both sides receive bonus credits on " + "completion. Omit if the user has no invite code.", }, }, required: ["email"], }, }, executor: this.signupStart.bind(this), }, ]; } async signupStart(args, ctx) { const email = typeof args.email === "string" ? args.email.trim().toLowerCase() : ""; if (!email || !EMAIL_REGEX.test(email)) { return { content: [ { type: "text", text: "that doesn't look like an email address — please ask the user to confirm and try again.", }, ], isError: true, }; } const orgName = typeof args.org_name === "string" && args.org_name.trim().length > 0 ? args.org_name.trim() : undefined; const purpose = typeof args.purpose === "string" && args.purpose.trim().length > 0 ? args.purpose.trim() : undefined; const referralCode = typeof args.referral_code === "string" && args.referral_code.trim().length > 0 ? args.referral_code.trim() : undefined; // The public endpoint is unauthenticated — same fetch shape as // pica_sign_in's call to /api/public/mcp-auth/authorize. const endpoint = `${this.apiUrl}/public/onboarding/signup-start`; let response; try { response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, ...(orgName ? { org_name: orgName } : {}), ...(purpose ? { purpose } : {}), ...(referralCode ? { referral_code: referralCode } : {}), transport: this.transport, ...(this.clientId ? { client_id: this.clientId } : {}), }), }); } catch (err) { const message = err instanceof Error ? err.message : "network error"; return { content: [ { type: "text", text: `couldn't reach withPICA to mint your signup link (${message}). check your internet connection and try again.`, }, ], isError: true, }; } if (response.status === 429) { const retryAfter = response.headers.get("Retry-After") ?? "a few minutes"; return { content: [ { type: "text", text: `too many signup attempts — please wait ${retryAfter} seconds before trying again. if the user just requested a link, ask them to check their inbox first.`, }, ], isError: true, }; } if (!response.ok) { let detail = "unknown error"; try { const body = (await response.json()); if (typeof body.error === "string") detail = body.error; } catch { // body wasn't JSON — fall back to status text detail = response.statusText || detail; } return { content: [ { type: "text", text: `couldn't mint your signup link: ${detail}. please try again or report this if it keeps happening.`, }, ], isError: true, }; } let body; try { body = (await response.json()); } catch { return { content: [ { type: "text", text: "the signup endpoint returned a malformed response. please try again.", }, ], isError: true, }; } if (!body.url || !body.expires_at) { return { content: [ { type: "text", text: "the signup endpoint returned an incomplete response. please try again.", }, ], isError: true, }; } const url = body.url; const expiresAt = body.expires_at; const textSummary = `signup link minted for ${email}. surface this URL to the user — they should click it to verify ownership of the email, ` + `pick an organisation name, and start their free runway. the link expires at ${expiresAt} (15 minutes from now). ` + (this.transport === "stdio" ? "after they finish signup the page will show a connection key to paste into Claude Desktop's MCP config; they'll then need to reconnect the PICA MCP server." : "after they finish signup the OAuth flow resumes automatically."); const resourceLink = { type: "resource_link", uri: url, name: "Sign up for withPICA", description: "Open this link to verify your email, create your catalog, and start your free runway.", mimeType: "text/html", }; const baseContent = [ { type: "text", text: JSON.stringify({ text_summary: textSummary, signup_url: url, expires_at: expiresAt, surface: body.surface ?? "onboarding", }), }, resourceLink, ]; // Capability-gated `elicitation/create url` — clients advertising // `capabilities.elicitation.url` get a server-issued consent prompt // to open the link in-line. Without the gate, calling elicitInput // against a non-supporter throws synchronously per // `feedback_mcp_sdk_capability_gates_elicitation.md`. if (clientSupportsUrlElicitation(ctx?.server)) { try { await ctx.server.elicitInput({ mode: "url", url, elicitationId: `pica-signup-${randomUUID()}`, message: `Open this URL to finish signing up for withPICA. Expires at ${expiresAt}.`, }, { timeout: 60_000 }); } catch { // user declined / cancelled / timed out — `resource_link` remains // usable so we degrade silently rather than fail the tool. } } return { content: baseContent, structuredContent: { kind: "pica.card.signup_start", version: 1, data: { signup_url: url, expires_at: expiresAt, email, transport: this.transport, }, }, }; } } //# sourceMappingURL=signup.js.map