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-Authenticateon 401 - RFC 7591 — Dynamic Client Registration (anonymous; public clients only — no secrets issued)
- RFC 6749 §4.1 — Authorization Code grant
- RFC 7636 — PKCE with
S256only (we refuseplain)
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
- Try the endpoint. POST
https://mcp.shipmcp.io/<slug>/mcpwith no auth → 401 +WWW-Authenticate: Bearer realm="ShipMCP", resource_metadata="...". - Discover the AS. GET
https://mcp.shipmcp.io/.well-known/oauth-protected-resource→{"authorization_servers": ["https://shipmcp.io"], ...}. - Read AS endpoints. GET
https://shipmcp.io/.well-known/oauth-authorization-server→ registration / authorize / token URLs. - Register itself. POST
/oauth/registerwith redirect_uris → returnsclient_id(no secret). - Open the consent screen. Browser navigates to
/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&state=.... - User logs in + consents. If they tick "Also grant write access," scope gets upgraded to
mcp:write. - Exchange code for token. POST
/oauth/tokenwith the code +code_verifier→{"access_token": "smcp_...", "token_type": "Bearer", "scope": "mcp" | "mcp:write"}. - Use the token. Subsequent
POST /<slug>/mcpcalls carryAuthorization: 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 parameterinvalid_grant— code not found / expired / already redeemed / PKCE failedinvalid_client— unknown client_idinvalid_redirect_uri— redirect_uri not in the registered set, or not https / localhostaccess_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.