MixCraft connects Claude to Apple Music. The infrastructure post explains why the server is hosted remotely — Apple Music requires server-side JWT signing with a private key, so a local MCP server wasn't an option. This post covers the other side: the Apple Music integration itself, the adapter pattern that makes the server extensible, and the Claude Code plugin that ships alongside it.

The source is at github.com/schuettc/mixcraft-app.

The Adapter Interface

The MCP server doesn't call Apple Music directly. It works through a MusicServiceAdapter interface that defines what any music service integration needs to support:

export interface MusicServiceAdapter {
  readonly serviceName: string;
  searchCatalog(params: SearchParams): Promise<SearchResult>;
  listPlaylists(tokens: UserTokens, limit?: number, offset?: number): Promise<Playlist[]>;
  getPlaylistTracks(playlistId: string, tokens: UserTokens): Promise<Track[]>;
  createPlaylist(name: string, tokens: UserTokens, description?: string, trackIds?: string[]): Promise<CreatePlaylistResult>;
  addTracks(playlistId: string, trackIds: string[], tokens: UserTokens): Promise<void>;
  getRecentlyPlayed(tokens: UserTokens, limit?: number): Promise<Track[]>;
  getLibrarySongs(tokens: UserTokens, limit?: number, offset?: number): Promise<Track[]>;
  addToLibrary(tokens: UserTokens, songIds?: string[], albumIds?: string[]): Promise<void>;
}

This exists because I didn't want the MCP server to know anything about Apple Music specifically. The server's job is to authenticate the user, figure out which services they've connected, and register the right tools. The adapter's job is to talk to the actual music API.

If I add Spotify later, it implements the same interface. The MCP server doesn't change — it just gets a new adapter to work with.

Dynamic Tool Registration

When a request comes in, the server checks what services the user has connected and registers MCP tools accordingly:

export function createMcpServer(
  services: Map<string, ServiceEntry>,
  portalUrl: string,
): McpServer {
  const server = new McpServer({ name: 'mixcraft-app', version: '1.0.0' });

  // No services connected — give the user a single helpful tool
  if (services.size === 0) {
    server.tool('get_started',
      'Get instructions for connecting your music service.',
      {},
      async () => ({
        content: [{ type: 'text', text: [
          'No music services connected yet.',
          `1. Visit ${portalUrl}`,
          '2. Sign in and connect your music service',
          '3. Restart this MCP session to access your music tools',
        ].join('\n') }],
      }),
    );
    return server;
  }

  const appleMusic = services.get('apple_music');
  if (appleMusic) {
    registerAppleMusicTools(server, appleMusic.adapter, appleMusic.tokens);
  }

  return server;
}

A user with no connected services sees a single get_started tool that tells them where to go. A user with Apple Music connected gets the full eight-tool suite. This means Claude always has something useful to say, even before the user has finished setup.

Apple Music Auth

Apple Music uses MusicKit, which requires two separate tokens on every API call. The developer token is a JWT that proves the app is authorized. The user token is an OAuth token that proves the user has granted access to their library.

The developer token is signed with the team's private key using ES256. This is the reason the server has to be hosted — this key can't be distributed to users. The token has a two-hour lifetime, and the server caches it and refreshes five minutes before expiry so requests don't fail during the regeneration window:

const TOKEN_LIFETIME_SECONDS = 3600 * 2;
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;

export async function generateDeveloperToken(): Promise<string> {
  const now = Date.now();

  // Return cached token if it's still valid (with 5-minute buffer)
  if (cachedToken && now < tokenExpiresAt - EXPIRY_BUFFER_MS) {
    return cachedToken;
  }

  // Fetch all three credentials in parallel
  const [teamId, keyId, privateKey] = await Promise.all([
    getSecret(process.env.APPLE_TEAM_ID_SECRET_NAME!),
    getSecret(process.env.APPLE_KEY_ID_SECRET_NAME!),
    getSecret(process.env.APPLE_PRIVATE_KEY_SECRET_NAME!),
  ]);

  const nowSeconds = Math.floor(now / 1000);
  cachedToken = jwt.sign(
    { iss: teamId, iat: nowSeconds, exp: nowSeconds + TOKEN_LIFETIME_SECONDS },
    privateKey,
    { algorithm: 'ES256', header: { alg: 'ES256', kid: keyId } },
  );
  tokenExpiresAt = now + TOKEN_LIFETIME_SECONDS * 1000;

  return cachedToken;
}

The three secrets — team ID, key ID, and private key — live in Secrets Manager and are fetched in parallel. In a Lambda environment with warm starts, the cached token avoids repeated Secrets Manager calls for most requests.

The user token comes from the portal. When someone connects their Apple Music account, MusicKit JS runs an OAuth popup in the browser. The resulting token is encrypted with KMS and stored in DynamoDB. On each MCP request, the server decrypts it and pairs it with the developer token.

The API Client

All Apple Music API calls go through a single fetch wrapper that handles auth headers, retries, and rate limiting:

export async function appleMusicFetch(
  endpoint: string,
  developerToken: string,
  userToken?: string | null,
  options: RequestInit = {},
): Promise<unknown> {
  const url = endpoint.startsWith('http')
    ? endpoint
    : `${APPLE_MUSIC_API_BASE}${endpoint}`;

  const headers = new Headers(options.headers);
  headers.set('Authorization', `Bearer ${developerToken}`);
  if (userToken) {
    headers.set('Music-User-Token', userToken);
  }

  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    if (attempt > 0) {
      await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
    }

    const response = await fetch(url, { ...options, headers });

    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      throw new RateLimitError(
        retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000,
        'Apple Music API rate limit hit',
      );
    }

    if (!response.ok) {
      const body = await response.text().catch(() => '');
      throw new MusicServiceError(response.status, `Apple Music API error ${response.status}: ${body}`);
    }

    return response.status === 204 ? null : await response.json();
  }
}

Apple Music uses two auth headers — Authorization: Bearer for the developer token and Music-User-Token for the user's OAuth token. Catalog searches only need the developer token. Anything that touches the user's library needs both.

The adapter translates between Apple Music's response format and the clean types the MCP server works with:

function toTrack(r: AppleMusicResource<AppleMusicSongAttributes>): Track {
  return {
    id: r.id,
    name: r.attributes.name,
    artistName: r.attributes.artistName,
    albumName: r.attributes.albumName,
    durationMs: r.attributes.durationInMillis,
    genre: r.attributes.genreNames?.[0],
  };
}

Pagination is handled at the adapter level. Playlist tracks use cursor-based pagination — the response includes a next URL when there are more pages. The adapter follows these links until the data is complete:

async getPlaylistTracks(playlistId: string, tokens: Tokens): Promise<Track[]> {
  const tracks: Track[] = [];
  let endpoint: string | null =
    `/me/library/playlists/${playlistId}/tracks?limit=100`;

  while (endpoint) {
    const raw = await appleMusicFetch(
      endpoint, tokens.developerToken, tokens.userToken,
    ) as AppleMusicTracksResponse;

    tracks.push(...raw.data.map(toTrack));
    endpoint = raw.next ?? null;
  }

  return tracks;
}

Immutability Constraints

Apple Music's API has a significant limitation: playlists created via the API cannot be deleted, renamed, or modified. Tracks added to a playlist cannot be removed or reordered. These operations work fine in the Apple Music app itself, but the API doesn't expose them.

This matters because Claude is the one creating playlists and adding tracks. Without guardrails, it could create permanent playlists the user didn't want. The tool descriptions include explicit warnings so Claude knows to confirm before making irreversible changes:

server.tool(
  'create_playlist',
  'Create a new playlist. WARNING: Playlists created via the Apple Music API ' +
  'CANNOT be deleted, renamed, or modified after creation. The name and ' +
  'description are permanent. Please confirm with the user before calling this tool.',
  { name: z.string(), description: z.string().optional(), trackIds: z.array(z.string()).optional() },
  async ({ name, description, trackIds }) => {
    const result = await adapter.createPlaylist(name, tokens, description, trackIds);
    return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
  },
);

The Claude Code Plugin

MixCraft ships as a Claude Code plugin, not just an MCP server. The MCP server gives Claude tools — search, create playlist, add tracks. The plugin adds a playlist-assistant skill that teaches Claude how to use those tools well.

The skill tells Claude to check the user's recently played tracks and library before recommending anything. It describes how to sequence playlists with energy arcs and genre bridges rather than dumping search results in order. It maintains a preference file at .claude/mixcraft.local.md so that taste insights carry across sessions — favorite artists, disliked genres, what works for different activities.

Install the plugin:

/plugin marketplace add schuettc/mixcraft-app
/plugin install mixcraft@mixcraft-app --scope project

Or skip the plugin and configure just the MCP server:

{
  "mcpServers": {
    "mixcraft": {
      "command": "npx",
      "args": ["-y", "mixcraft-app@latest"],
      "env": { "MIXCRAFT_API_KEY": "mx_your_key_here" }
    }
  }
}

The plugin gives you both the tools and the curation skill. The raw MCP config gives you just the tools. For most users, the plugin is the better experience.

The infrastructure post covers the proxy pattern, token encryption, and CDK stack.

The full source is at github.com/schuettc/mixcraft-app.