@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
285 lines • 13.3 kB
JavaScript
// 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