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