Several months ago, I was looking for some new music and decided to give Claude a crack at finding me some new music.
i'm trying to make an album based playlist. It should consist of 3 (mostly) complete albums.
Right now I have Hum - Downward is Heavenward and Weezer - Pinkerton. These two go together very well. Is there a third album we can include in this?
This fairly innocuous prompt resulted in Claude suggested a completely unknown to me band that very quickly became one of my favorites.
Those are excellent album choices! Hum's "Downward is Heavenward" with its spacey alt-rock sound and Weezer's "Pinkerton" with its raw emotional energy make for a compelling combination.
For a third album that would complement these two well, I'd recommend Failure - "Fantastic Planet" (1996).
At first I was a little skeptical, but Claude also listed out some other albums and they were all reasonable suggestions, so I gave Failure a try. I was instantly hooked.
Because of this success, I wanted to create a system that would allow me to give access to my Apple Music account to Claude to create and edit playlists directly. The (eventual) result is MixCraft It's a hosted MCP server running on AWS instead of on locally. This wasn't the original plan. I started building a normal local MCP server, and Apple Music's auth model pushed me onto a different path.
A companion post covers the Apple Music integration and Claude Code skills.
The Problem with Local
Apple Music's API uses MusicKit. Every API request needs a developer token — a JWT signed with an ES256 private key tied to your Apple Developer account. The private key can't leave your server. You definitely can't bundle it into an npm package or ask users to go get their own Apple Developer credentials.
This is the constraint that pushed the server remote. If the API required a simple bearer token or OAuth client credentials, a local MCP server would have been fine. But the signing has to happen server-side, which lead me to develop a centralized, and thus hosted version.
Once you accept that the server is centralized and hosted, other things get simpler. User tokens can be encrypted with KMS before they touch the database. API keys can be stored as hashes instead of plaintext. You get multi-tenancy without asking users to set up anything beyond an API key.
Architecture
graph LR
Claude[Claude] <-->|stdio| Proxy[MCP Proxy CLI]
Proxy <-->|HTTPS| MCP[mcp.mixcraft.app<br/>Lambda]
MCP <-->|REST| Apple[Apple Music API]
Portal[mixcraft.app<br/>CloudFront + S3] <-->|HTTPS| API[api.mixcraft.app<br/>Lambda]
API <--> DB[(DynamoDB)]
MCP <--> DB
Three domains handle different concerns. mixcraft.app is the portal — a React app on CloudFront + S3 where users sign up, connect their Apple Music account, and generate API keys. api.mixcraft.app is the portal's backend — handles Clerk webhooks, token storage, key management. mcp.mixcraft.app is the MCP API itself — receives tool calls from Claude, validates the API key, decrypts the user's tokens, calls Apple Music, returns results.
The portal and MCP API share the same DynamoDB tables but run as separate Lambda functions with different IAM permissions. The portal API can write tokens and create keys. The MCP API can read tokens and validate keys. Neither has permissions it doesn't need.
The Proxy as a Transport Bridge
MCP clients like Claude expect to talk to servers over stdio — launch a process, pipe JSON-RPC in and out. A remote server speaks HTTP. Something needs to bridge the gap.
The proxy in this project is a thin CLI that acts as an MCP client on one side and an MCP server on the other. On startup, it connects to the remote MixCraft API over HTTP, discovers what tools are available, then re-exposes those same tools locally over stdio. When Claude calls a tool, the proxy forwards the call to the remote server and passes the response back.
async function main(): Promise<void> {
// Connect to the remote MCP server as a client
const remoteTransport = new StreamableHTTPClientTransport(
new URL('https://mcp.mixcraft.app/mcp'),
{
requestInit: {
headers: { Authorization: `Bearer ${apiKey}` },
},
},
);
const remoteClient = new Client({ name: 'mixcraft-cli', version: '0.2.0' });
await remoteClient.connect(remoteTransport);
// Discover what tools the remote server offers
const { tools } = await remoteClient.listTools();
// Create a local MCP server that mirrors each remote tool
const localServer = new McpServer({ name: 'mixcraft-app', version: '0.2.0' });
for (const tool of tools) {
const zodShape = buildZodShape(tool);
localServer.tool(tool.name, tool.description ?? '', zodShape,
async (args) => {
const result = await remoteClient.callTool({
name: tool.name,
arguments: args,
});
return { content: result.content, isError: result.isError };
},
);
}
// Expose the local server over stdio
const stdioTransport = new StdioServerTransport();
await localServer.connect(stdioTransport);
}
From Claude's perspective, this looks like any other local MCP server. The configuration is the same npx pattern:
{
"mcpServers": {
"mixcraft": {
"command": "npx",
"args": ["-y", "mixcraft-app@latest"],
"env": {
"MIXCRAFT_API_KEY": "mx_your_key_here"
}
}
}
}
The user doesn't need to know or care that the tools are running remotely. The proxy handles the transport translation transparently.
Token Security
Since Mixcraft holds users' Apple Music tokens, it needs to handle them carefully. When a user connects their account through the portal, the OAuth token is encrypted with KMS before it's stored in DynamoDB. The Lambda functions never see or log plaintext tokens — they encrypt on write and decrypt on read.
export async function encryptToken(token: string, keyArn: string): Promise<string> {
const command = new EncryptCommand({
KeyId: keyArn,
Plaintext: Buffer.from(token, 'utf-8'),
});
const data = await kmsClient.send(command);
return Buffer.from(data.CiphertextBlob).toString('base64');
}
export async function decryptToken(encryptedBase64: string, keyArn: string): Promise<string> {
const command = new DecryptCommand({
KeyId: keyArn,
CiphertextBlob: Buffer.from(encryptedBase64, 'base64'),
});
const data = await kmsClient.send(command);
return Buffer.from(data.Plaintext).toString('utf-8');
}
API keys follow a similar principle. The raw key (mx_<random>) is shown to the user once at creation. What gets stored in DynamoDB is a SHA-256 hash. On each request, the incoming key is hashed and looked up — if it matches, the request is authenticated.
export function hashApiKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
export async function validateApiKey(apiKey: string): Promise<ApiKeyRecord> {
if (!apiKey.startsWith('mx_')) {
throw new AuthenticationError('Invalid API key format');
}
const keyHash = hashApiKey(apiKey);
const result = await client.send(
new GetCommand({ TableName: tableName(), Key: { keyHash } }),
);
if (!result.Item || result.Item.isActive !== true) {
throw new AuthenticationError('Invalid API key');
}
// Update lastUsedAt without blocking the request
client.send(
new UpdateCommand({
TableName: tableName(),
Key: { keyHash },
UpdateExpression: 'SET lastUsedAt = :now',
ExpressionAttributeValues: { ':now': new Date().toISOString() },
}),
).catch(() => {});
return { userId: result.Item.userId, keyPrefix: result.Item.keyPrefix, name: result.Item.name };
}
The lastUsedAt update is fire-and-forget. It's useful for the dashboard but shouldn't slow down tool calls.
The CDK Stack
The whole project is a TypeScript monorepo with pnpm workspaces. The infrastructure is CDK with constructs that map to the architecture domains — one for the database, one for security, one for the MCP API, one for the portal API, one for the portal frontend.
The database construct creates three DynamoDB tables — users, API keys, and encrypted tokens. All use on-demand billing and AWS-managed encryption:
// UsersTable: PK userId -> email, plan
this.usersTable = new Table(this, 'UsersTable', {
partitionKey: { name: 'userId', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
encryption: TableEncryption.AWS_MANAGED,
pointInTimeRecovery: true,
});
// ApiKeysTable: PK keyHash -> userId, keyPrefix, name, isActive
this.apiKeysTable = new Table(this, 'ApiKeysTable', {
partitionKey: { name: 'keyHash', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
encryption: TableEncryption.AWS_MANAGED,
pointInTimeRecovery: true,
});
// GSI for listing a user's keys
this.apiKeysTable.addGlobalSecondaryIndex({
indexName: 'UserIdIndex',
partitionKey: { name: 'userId', type: AttributeType.STRING },
sortKey: { name: 'createdAt', type: AttributeType.STRING },
});
// UserMusicTokensTable: PK userId + SK service -> encryptedToken
this.userMusicTokensTable = new Table(this, 'UserMusicTokensTable', {
partitionKey: { name: 'userId', type: AttributeType.STRING },
sortKey: { name: 'service', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
encryption: TableEncryption.AWS_MANAGED,
pointInTimeRecovery: true,
});
The ApiKeysTable is keyed by keyHash because that's how lookups happen — hash the incoming key, go directly to the record. The GSI on userId exists for the portal dashboard, where users need to see their own keys.
The UserMusicTokensTable uses a composite key — userId + service — so a single user can connect multiple music services. Right now it's just Apple Music, but the schema supports Spotify or anything else without migration.
The security construct creates a KMS key with automatic rotation and references the pre-existing Secrets Manager secrets for Apple credentials:
this.tokenEncryptionKey = new kms.Key(this, 'TokenEncryptionKey', {
description: 'Encryption key for user music service tokens',
enableKeyRotation: true,
});
this.appleTeamIdSecret = secretsmanager.Secret.fromSecretNameV2(
this, 'AppleTeamIdSecret', props.appleTeamIdSecretName,
);
The secrets aren't created by CDK. They're uploaded separately via a script and CDK only references them for IAM grants. This keeps sensitive values out of the CDK context and CloudFormation parameters.
The MCP API construct wires it together — a Lambda function bundled with esbuild, an HTTP API Gateway with a custom domain, and the minimum IAM grants for each resource:
this.mcpFunction = new NodejsFunction(this, 'McpFunction', {
entry: path.join(__dirname, '..', '..', '..', 'mcp-server', 'src', 'index.ts'),
runtime: Runtime.NODEJS_20_X,
memorySize: 512,
timeout: Duration.seconds(30),
bundling: { minify: true, sourceMap: true, target: 'node20' },
});
// Each grant is specific to what the function needs
props.usersTable.grantReadWriteData(this.mcpFunction);
props.apiKeysTable.grantReadWriteData(this.mcpFunction);
props.tokenEncryptionKey.grantEncryptDecrypt(this.mcpFunction);
props.appleTeamIdSecret.grantRead(this.mcpFunction);
The portal uses CloudFront + S3 with error responses configured for SPA routing — 403 and 404 both return /index.html so client-side routing works:
this.distribution = new cloudfront.Distribution(this, 'PortalDistribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' },
],
});
The companion post covers how the Apple Music adapter works, the MusicKit auth flow, and the Claude Code plugin that ships alongside the MCP server.
The full source is at github.com/schuettc/mixcraft-app.
/