MixCraft started with static API keys—generate one from the web portal, paste it into your Claude Code config. The mx_ prefixed keys had no expiration, lived in shell history and dotfiles, and revoking one meant generating a new key and updating every client. Service tokens from third-party APIs expired after an hour with no refresh mechanism, so users would get silent failures mid-session. I needed to replace the whole auth model with OAuth. But worse, it didn't work well with claude.ai because we had no way to easily create a connector.

The Architecture

The system now has two authentication paths for MCP clients:

graph TB
    subgraph "Claude Code / Desktop"
        F[npx mixcraft-app] --> G[Discover OAuth metadata]
        G --> H[Open browser with PKCE challenge]
        H --> I[User authenticates with Clerk]
        I --> J[Local server receives callback]
        J --> K[Token cached at ~/.mixcraft/token.json]
    end

    subgraph "claude.ai Connector"
        L[Add connector URL] --> M[OAuth discovery via RFC 8414]
        M --> N[Clerk handles auth flow]
        N --> O[Token sent per request]
    end

    K --> P[MCP Server]
    O --> P
    P --> Q{Validate token}
    Q --> R[Clerk JWT verification]
    Q --> S[Userinfo endpoint fallback]

PKCE for the CLI

The CLI (npx mixcraft-app) needed to authenticate without a server-side backend. PKCE (Proof Key for Code Exchange) is typically associated with mobile apps, but it works perfectly for CLI tools with a local callback server.

On first run, the CLI:

  1. Discovers OAuth endpoints from /.well-known/oauth-authorization-server
  2. Generates a PKCE code verifier and challenge
  3. Opens the user's browser to Clerk's authorization endpoint
  4. Spins up a temporary HTTP server on localhost:8888
  5. Receives the callback, exchanges the code for tokens
  6. Caches tokens at ~/.mixcraft/token.json with 0o600 permissions
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
  .update(codeVerifier)
  .digest('base64url');

// Open browser
const authUrl = new URL(authorizationEndpoint);
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('redirect_uri', 'http://localhost:8888/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'profile email');

On subsequent runs, the CLI checks the cached token. If it's within 60 seconds of expiry, it refreshes proactively using the refresh token. If a request returns 401, it refreshes reactively and retries once. The user only sees the browser login on first use.

The 60-second buffer matters. Without it, you'd occasionally send a token that expires between the check and the request arriving at the server. A small race window, but one that causes confusing intermittent failures.

OAuth Server Discovery

The MCP server exposes RFC 8414 metadata at /.well-known/oauth-authorization-server:

{
  "issuer": "https://clerk.mixcraft.app",
  "authorization_endpoint": "https://clerk.mixcraft.app/oauth/authorize",
  "token_endpoint": "https://clerk.mixcraft.app/oauth/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"]
}

This serves two purposes. The CLI discovers endpoints automatically without hardcoding URLs. And claude.ai's connector system can configure itself with just the server URL—it reads the metadata to find the authorization and token endpoints.

Adding a custom MCP connector in claude.ai becomes a three-field form: URL, client ID, and name. The OAuth discovery handles everything else.

Dual Authentication in the MCP Server

The MCP server now validates tokens from multiple sources. The CLI sends cached Clerk JWTs. The claude.ai connector sends OAuth access tokens. Legacy API keys (being deprecated) still work for backward compatibility.

async function authenticate(token: string): Promise<AuthResult> {
  // Legacy API key support (deprecated)
  if (token.startsWith('mx_')) {
    return validateApiKey(token);
  }

  // Try JWT verification first (CLI/Desktop)
  try {
    const payload = await verifyToken(token, {
      secretKey: clerkSecretKey,
    });
    return { userId: payload.sub, authMethod: 'clerk_jwt' };
  } catch {
    // Fall through to userinfo
  }

  // Fallback to userinfo endpoint (claude.ai connector)
  const response = await fetch(
    'https://clerk.mixcraft.app/oauth/userinfo',
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const userinfo = await response.json();
  return { userId: userinfo.user_id, authMethod: 'clerk_oauth' };
}

The JWT path is fast—local cryptographic verification with no network call. The userinfo fallback is necessary because claude.ai sends opaque OAuth access tokens that can't be verified locally. The order matters: try the cheap operation first, fall back to the network call only when needed.

What Changed for Users

Before: Generate API key → copy from portal → paste into claude_desktop_config.json → manually manage the secret → tokens expire silently after an hour → re-authenticate manually.

After (Claude Code): Run npx mixcraft-app@latest → browser opens → sign in once → done. Token refreshes automatically. Subsequent runs skip authentication entirely.

After (claude.ai): Add connector URL → authenticate once → done. Token management is invisible.

Backward Compatibility

The old API key system still works. The CLI detects existing MX_API_KEY environment variables and uses them, but prints a deprecation warning pointing users to the new OAuth flow. This avoids breaking existing setups while encouraging migration.

The full implementation is in the MixCraft repository. The MCP server, CLI proxy, and web portal are all in the packages/ directory.