From 75bddc3ea9ccc1124a34403e549846d7d8575662 Mon Sep 17 00:00:00 2001 From: Andrei Pop Date: Sun, 8 Feb 2026 14:38:11 -0500 Subject: [PATCH 1/3] Add Vercel deployment for remote MCP server Deploy Apple Music MCP as a stateless Vercel serverless function with bearer token auth, per-request Music User Token support, and a hosted auth page for onboarding users via MusicKit JS. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + api/auth.ts | 46 ++++++ api/mcp.ts | 106 ++++++++++++ package-lock.json | 1 + package.json | 2 + public/auth.html | 275 +++++++++++++++++++++++++++++++ src/auth/developer-token-jose.ts | 16 ++ src/config-vercel.ts | 44 +++++ vercel.json | 24 +++ 9 files changed, 515 insertions(+) create mode 100644 api/auth.ts create mode 100644 api/mcp.ts create mode 100644 public/auth.html create mode 100644 src/auth/developer-token-jose.ts create mode 100644 src/config-vercel.ts create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index b525a61..cd540b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ tokens.json .mcp.json .DS_Store +.vercel diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 0000000..64b70c0 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadVercelConfig } from "../src/config-vercel.js"; +import { generateDeveloperTokenJose } from "../src/auth/developer-token-jose.js"; + +// Cache developer token across warm invocations +let cachedDevToken: string | null = null; +let tokenExpiry = 0; + +async function getDeveloperToken(): Promise { + const now = Date.now(); + if (cachedDevToken && now < tokenExpiry) { + return cachedDevToken; + } + const config = loadVercelConfig(); + cachedDevToken = await generateDeveloperTokenJose( + config.teamId, + config.keyId, + config.privateKeyPem + ); + tokenExpiry = now + 23 * 60 * 60 * 1000; + return cachedDevToken; +} + +export default { + async fetch(_request: Request): Promise { + try { + const devToken = await getDeveloperToken(); + + // Read the static HTML and inject the developer token as a meta tag + const htmlPath = join(process.cwd(), "public", "auth.html"); + let html = readFileSync(htmlPath, "utf8"); + html = html.replace( + "", + ` \n` + ); + + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } catch (err: unknown) { + return new Response(`Auth page error: ${String(err)}`, { status: 500 }); + } + }, +}; diff --git a/api/mcp.ts b/api/mcp.ts new file mode 100644 index 0000000..0b4bb0c --- /dev/null +++ b/api/mcp.ts @@ -0,0 +1,106 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { loadVercelConfig } from "../src/config-vercel.js"; +import { generateDeveloperTokenJose } from "../src/auth/developer-token-jose.js"; +import { AppleMusicClient } from "../src/api/client.js"; +import { TokenStore } from "../src/auth/token-store.js"; +import { registerAllTools } from "../src/tools/index.js"; + +// Cache developer token across warm invocations (same Vercel container) +let cachedDevToken: string | null = null; +let tokenExpiry = 0; + +async function getDeveloperToken( + teamId: string, + keyId: string, + privateKeyPem: string +): Promise { + const now = Date.now(); + if (cachedDevToken && now < tokenExpiry) { + return cachedDevToken; + } + cachedDevToken = await generateDeveloperTokenJose(teamId, keyId, privateKeyPem); + tokenExpiry = now + 23 * 60 * 60 * 1000; // regenerate every 23 hours + return cachedDevToken; +} + +function createEnvTokenStore( + devToken: string, + userToken?: string +): TokenStore { + return { + getDeveloperToken: () => devToken, + getMusicUserToken: () => userToken, + isDeveloperTokenExpired: () => false, + isUserTokenExpired: () => false, + setDeveloperToken: () => {}, + setMusicUserToken: () => {}, + } as unknown as TokenStore; +} + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, Music-User-Token, mcp-session-id, Last-Event-ID, mcp-protocol-version", + "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version", +}; + +async function handleMcpRequest(request: Request): Promise { + // Handle CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + // Bearer token auth + const apiKey = process.env.MCP_API_KEY; + if (apiKey) { + const authHeader = request.headers.get("Authorization"); + if (authHeader !== `Bearer ${apiKey}`) { + return new Response("Unauthorized", { status: 401, headers: CORS_HEADERS }); + } + } + + const config = loadVercelConfig(); + const devToken = await getDeveloperToken( + config.teamId, + config.keyId, + config.privateKeyPem + ); + + // User token: prefer request header, fall back to env var + const musicUserToken = + request.headers.get("Music-User-Token") || config.musicUserToken; + + const tokenStore = createEnvTokenStore(devToken, musicUserToken); + const client = new AppleMusicClient(tokenStore, devToken, config.storefront); + + const server = new McpServer({ + name: "apple-music-remote", + version: "1.0.0", + }); + + registerAllTools(server, client); + + const transport = new WebStandardStreamableHTTPServerTransport(); + await server.connect(transport); + + const response = await transport.handleRequest(request); + + // Add CORS headers to the response + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +// Vercel Web Standard fetch export +export default { + fetch: handleMcpRequest, +}; diff --git a/package-lock.json b/package-lock.json index 4144f5e..173d416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "jose": "^6.1.3", "jsonwebtoken": "^9.0.2", "open": "^10.1.0", "zod": "^3.24.0" diff --git a/package.json b/package.json index 352b98a..ec14283 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ }, "scripts": { "build": "tsc && cp -r src/auth-page dist/auth-page", + "vercel-build": "tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", "auth": "tsx src/auth/user-token.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "jose": "^6.1.3", "jsonwebtoken": "^9.0.2", "open": "^10.1.0", "zod": "^3.24.0" diff --git a/public/auth.html b/public/auth.html new file mode 100644 index 0000000..2a76185 --- /dev/null +++ b/public/auth.html @@ -0,0 +1,275 @@ + + + + + + Apple Music MCP - Authorize + + + +
+
+

Apple Music MCP Server

+

Authorize access to your Apple Music library to use the MCP server with Claude Desktop, Claude Code, or any MCP client.

+ +
+
+ +
+

Authorization Successful

+ +
+
Your Music User Token
+
+ +
+ +
+

Setup Instructions

+
    +
  1. Open your Claude Desktop config file:
    + ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
    + %APPDATA%\Claude\claude_desktop_config.json (Windows) +
  2. +
  3. Add this MCP server entry inside "mcpServers":
  4. +
+
+

+          
+        
+
    +
  1. Restart Claude Desktop
  2. +
  3. Ask Claude to search for a song or list your playlists to verify
  4. +
+
+ +
+ Token expiry: Your Music User Token is valid for approximately 6 months. When it expires, library tools (playlists, recently played, etc.) will return auth errors. Come back to this page to re-authorize and get a new token. Catalog search works without a user token. +
+
+
+ + + + + diff --git a/src/auth/developer-token-jose.ts b/src/auth/developer-token-jose.ts new file mode 100644 index 0000000..5317610 --- /dev/null +++ b/src/auth/developer-token-jose.ts @@ -0,0 +1,16 @@ +import { SignJWT, importPKCS8 } from "jose"; + +export async function generateDeveloperTokenJose( + teamId: string, + keyId: string, + privateKeyPem: string +): Promise { + const privateKey = await importPKCS8(privateKeyPem, "ES256"); + + const now = Math.floor(Date.now() / 1000); + const exp = now + 180 * 24 * 60 * 60; // 180 days max + + return new SignJWT({ iss: teamId, iat: now, exp }) + .setProtectedHeader({ alg: "ES256", kid: keyId }) + .sign(privateKey); +} diff --git a/src/config-vercel.ts b/src/config-vercel.ts new file mode 100644 index 0000000..22d6ec1 --- /dev/null +++ b/src/config-vercel.ts @@ -0,0 +1,44 @@ +import { ConfigurationError } from "./utils/errors.js"; + +export interface VercelAppleMusicConfig { + teamId: string; + keyId: string; + privateKeyPem: string; + storefront: string; + musicUserToken?: string; +} + +export function loadVercelConfig(): VercelAppleMusicConfig { + const teamId = process.env.APPLE_MUSIC_TEAM_ID; + const keyId = process.env.APPLE_MUSIC_KEY_ID; + const privateKeyBase64 = process.env.APPLE_MUSIC_PRIVATE_KEY; + const musicUserToken = process.env.APPLE_MUSIC_USER_TOKEN; + + if (!teamId) { + throw new ConfigurationError( + "APPLE_MUSIC_TEAM_ID environment variable is required." + ); + } + + if (!keyId) { + throw new ConfigurationError( + "APPLE_MUSIC_KEY_ID environment variable is required." + ); + } + + if (!privateKeyBase64) { + throw new ConfigurationError( + "APPLE_MUSIC_PRIVATE_KEY environment variable is required (base64-encoded .p8 key)." + ); + } + + const privateKeyPem = Buffer.from(privateKeyBase64, "base64").toString("utf8"); + + return { + teamId, + keyId, + privateKeyPem, + storefront: process.env.APPLE_MUSIC_STOREFRONT || "us", + musicUserToken, + }; +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0dce2e2 --- /dev/null +++ b/vercel.json @@ -0,0 +1,24 @@ +{ + "buildCommand": "npm run vercel-build", + "outputDirectory": ".", + "functions": { + "api/mcp.ts": { + "maxDuration": 60 + } + }, + "rewrites": [ + { "source": "/mcp", "destination": "/api/mcp" }, + { "source": "/auth", "destination": "/api/auth" } + ], + "headers": [ + { + "source": "/mcp", + "headers": [ + { "key": "Access-Control-Allow-Origin", "value": "*" }, + { "key": "Access-Control-Allow-Methods", "value": "GET, POST, DELETE, OPTIONS" }, + { "key": "Access-Control-Allow-Headers", "value": "Content-Type, mcp-session-id, Last-Event-ID, mcp-protocol-version" }, + { "key": "Access-Control-Expose-Headers", "value": "mcp-session-id, mcp-protocol-version" } + ] + } + ] +} From 08ece8beae475a0f0c941d708d377268bec7d278 Mon Sep 17 00:00:00 2001 From: Andrei Pop Date: Sun, 8 Feb 2026 14:41:19 -0500 Subject: [PATCH 2/3] Update README with hosted quick start and remote deployment docs Add a "Quick Start (Hosted)" section as the primary onboarding path so users can get started without an Apple Developer account. Reorganize the self-hosted setup under its own section and document both local and remote architecture modes. Co-Authored-By: Claude Opus 4.6 --- README.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dbd14bb..c06d0a1 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,62 @@ An MCP (Model Context Protocol) server that integrates with Apple Music, allowin - **View** recently played tracks - **Get** personalized recommendations -## Prerequisites +## Quick Start (Hosted) + +The fastest way to get started — no Apple Developer account, no local setup, no environment variables. Just authorize and go. + +### 1. Get your Music User Token + +Visit **[applemusicmcp.gradientworks.ca/auth](https://applemusicmcp.gradientworks.ca/auth)** and click "Authorize with Apple Music". Sign in with your Apple ID and copy the token shown on the page. + +### 2. Add to Claude Desktop + +Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows): + +```json +{ + "mcpServers": { + "apple-music-remote": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://applemusicmcp.gradientworks.ca/mcp", + "--header", + "Music-User-Token: YOUR_TOKEN_HERE" + ] + } + } +} +``` + +Replace `YOUR_TOKEN_HERE` with the token you copied. Restart Claude Desktop. + +### 3. Add to Claude Code + +```bash +claude mcp add apple-music-remote \ + -- npx -y mcp-remote https://applemusicmcp.gradientworks.ca/mcp \ + --header "Music-User-Token: YOUR_TOKEN_HERE" +``` + +Your Music User Token is valid for approximately **6 months**. When it expires, revisit the auth page to get a new one. + +--- + +## Self-Hosted Setup + +If you prefer to run the server yourself, follow the instructions below. + +### Prerequisites - Node.js 18+ - An [Apple Developer Program](https://developer.apple.com/programs/) membership - A MusicKit identifier and private key -## Apple Developer Setup +### Apple Developer Setup -### 1. Create a MusicKit Identifier +#### 1. Create a MusicKit Identifier 1. Go to [Apple Developer > Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) 2. Click **+** to register a new identifier @@ -30,7 +77,7 @@ An MCP (Model Context Protocol) server that integrates with Apple Music, allowin 4. Enter a description (e.g., "MCP Server") and an identifier (e.g., `com.yourname.musicmcp`) 5. Click **Continue** and **Register** -### 2. Create a MusicKit Private Key +#### 2. Create a MusicKit Private Key 1. Go to [Apple Developer > Keys](https://developer.apple.com/account/resources/authkeys/list) 2. Click **+** to create a new key @@ -40,11 +87,11 @@ An MCP (Model Context Protocol) server that integrates with Apple Music, allowin 6. **Download the .p8 file** (you can only download it once!) 7. Note the **Key ID** shown on the page -### 3. Find Your Team ID +#### 3. Find Your Team ID Your Team ID is visible at the top right of the Apple Developer portal, or under **Membership Details**. -## Installation +### Installation ```bash git clone @@ -53,7 +100,7 @@ npm install npm run build ``` -## Configuration +### Configuration The server is configured via environment variables: @@ -66,7 +113,7 @@ The server is configured via environment variables: | `APPLE_MUSIC_CONFIG_DIR` | No | Config directory path (default: `~/.apple-music-mcp/`) | | `APPLE_MUSIC_AUTH_PORT` | No | Port for auth server (default: `7829`) | -## Authorization +### Authorization Before using library features (playlists, library songs, recommendations), you need to authorize with your Apple Music account: @@ -85,7 +132,7 @@ This will: **Note:** Catalog search works without authorization. Only library/personal features require it. -## Adding to Claude Desktop +### Adding to Claude Desktop (Local) Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): @@ -106,7 +153,7 @@ Add this to your Claude Desktop config (`~/Library/Application Support/Claude/cl } ``` -## Adding to Claude Code +### Adding to Claude Code (Local) Add to your Claude Code settings (`.claude/settings.json` or global settings): @@ -213,8 +260,10 @@ npm start # Run compiled version ## Architecture -- **Transport:** stdio (JSON-RPC over stdin/stdout) +- **Local mode:** stdio transport (JSON-RPC over stdin/stdout) +- **Remote mode:** Stateless HTTP on Vercel via `WebStandardStreamableHTTPServerTransport` - **API:** Direct REST calls to `api.music.apple.com/v1/` -- **Auth:** ES256 JWT developer tokens + browser-based MusicKit JS for user tokens +- **Auth:** ES256 JWT developer tokens (signed with `jose`) + browser-based MusicKit JS for user tokens +- **Multi-user:** Music User Token passed per-request as a header (no server-side storage) - **Caching:** In-memory with per-endpoint TTLs - **Error handling:** Typed errors with helpful messages and retry logic for rate limits From fb97f115f12e9eb99176925bb5fa867a7b73b8d3 Mon Sep 17 00:00:00 2001 From: Andrei Pop Date: Sun, 8 Feb 2026 14:51:40 -0500 Subject: [PATCH 3/3] Remove real credentials from deployment plan, harden .gitignore Replace hardcoded Team ID and Key ID with placeholders in VERCEL_DEPLOYMENT_PLAN.md. Expand .gitignore to cover all .env* variants (e.g. .env.local, .env.production). Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cd540b2..cb944e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules/ dist/ *.p8 -.env +.env* tokens.json .mcp.json .DS_Store