OAuth 2.0 + Dynamic Client Registration

ShipMCP implements the slice of OAuth 2.0 modern MCP clients (Claude.ai web, Cursor, MCP Inspector) need to auto-connect — no copy-pasting bearer tokens.

What we implement

  • RFC 8414 — Authorization Server Metadata at /.well-known/oauth-authorization-server
  • RFC 9728 — Protected Resource Metadata at /.well-known/oauth-protected-resource + WWW-Authenticate on 401
  • RFC 7591 — Dynamic Client Registration (anonymous; public clients only — no secrets issued)
  • RFC 6749 §4.1 — Authorization Code grant
  • RFC 7636 — PKCE with S256 only (we refuse plain)

Two scopes:

  • mcp — read-only (default)
  • mcp:write — read + queue-writes (insert/update/delete) + ingest tools. Granted only when the user explicitly ticks the consent-screen checkbox.

The flow Claude.ai (or any modern client) runs

  1. Try the endpoint. POST https://mcp.shipmcp.io/<slug>/mcp with no auth → 401 + WWW-Authenticate: Bearer realm="ShipMCP", resource_metadata="...".
  2. Discover the AS. GET https://mcp.shipmcp.io/.well-known/oauth-protected-resource{"authorization_servers": ["https://shipmcp.io"], ...}.
  3. Read AS endpoints. GET https://shipmcp.io/.well-known/oauth-authorization-server → registration / authorize / token URLs.
  4. Register itself. POST /oauth/register with redirect_uris → returns client_id (no secret).
  5. Open the consent screen. Browser navigates to /oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&state=....
  6. User logs in + consents. If they tick "Also grant write access," scope gets upgraded to mcp:write.
  7. Exchange code for token. POST /oauth/token with the code + code_verifier{"access_token": "smcp_...", "token_type": "Bearer", "scope": "mcp" | "mcp:write"}.
  8. Use the token. Subsequent POST /<slug>/mcp calls carry Authorization: Bearer smcp_....

Dynamic Client Registration request

POST /oauth/register
Content-Type: application/json

{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "client_name": "Claude.ai"
}

// Response (201)
{
  "client_id": "e8b6119f-d9ef-4cf1-8dbc-b000a9d7b51a",
  "client_id_issued_at": 1777336227,
  "client_name": "Claude.ai",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Registration is anonymous (no auth header) per RFC 7591. We accept that because we only mint public clients (no secrets), so a registered client_id on its own confers no privilege — the actual token issuance still requires a real human user to log in and consent on shipmcp.io.

Allowed redirect URIs: https://* or http://localhost* / http://127.0.0.1* for development. Anything else (e.g. javascript:, http://example.com) is rejected with invalid_redirect_uri.

Authorization endpoint

GET /oauth/authorize
  ?response_type=code
  &client_id=<from /oauth/register>
  &redirect_uri=<must match what was registered>
  &code_challenge=<base64url(SHA256(code_verifier))>
  &code_challenge_method=S256
  &state=<opaque, echoed back>
  &scope=mcp           // or mcp:write

If the user isn't signed into shipmcp.io, we redirect to /auth/login?next=<encoded-current-url>, then bounce back here after they authenticate. The consent screen always offers an "Also grant write access" upgrade checkbox when the user has at least one writeable endpoint, regardless of which scope the client requested — so users can elevate even when the client only knows about mcp.

On Allow, redirects to redirect_uri?code=<code>&state=<state>. On Deny, redirects with ?error=access_denied.

Token endpoint

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<from authorize redirect>
&redirect_uri=<must match>
&client_id=<from register>
&code_verifier=<the un-hashed verifier>

// Response
{
  "access_token": "smcp_...",
  "token_type": "Bearer",
  "scope": "mcp" | "mcp:write"
}

Tokens are tenant-scoped (cover every endpoint the granting tenant owns) and don't expire — users revoke from the dashboard at /endpoints/<id>. Authorization codes are single-use, 5-minute TTL, and PKCE-protected against interception.

Error envelope

Per RFC 6749 §5.2, errors come back as JSON: {"error": "invalid_grant", "error_description": "..."} with the appropriate HTTP status. Common codes you'll see:

  • invalid_request — missing required parameter
  • invalid_grant — code not found / expired / already redeemed / PKCE failed
  • invalid_client — unknown client_id
  • invalid_redirect_uri — redirect_uri not in the registered set, or not https / localhost
  • access_denied — user clicked Deny on the consent screen (echoed via redirect, not in a token response)

Hint for connection-time scope requests

If the user appends ?scope=mcp:write to their MCP server URL when adding it to a client, our WWW-Authenticate response on 401 echoes scope="mcp:write" back. OAuth-aware clients pick this up and request the matching scope on /oauth/authorize, so the consent checkbox arrives pre-ticked. Most clients still ignore the hint, but the consent-screen upgrade path covers them all.