MCP OAuth & settings-backed auth
Some MCP servers (e.g. Superhuman Mail) authenticate with OAuth, not a static API key. These two PRs give the whole stack one typed way to capture, encrypt, persist and reuse MCP credentials — so an OAuth server is installed once and just works on the next conversation, local or cloud. sdk#3964 defines the model + runtime; canvas#1583 builds the install UI on top of it.
1The big picture
Read it as a contract and its consumer. The SDK PR is the contract: it defines a tagged-union credential type, stores secrets encrypted through normal settings serialization, and adds the agent-server OAuth endpoints. The Canvas PR is the consumer: it adds the install/edit UI that speaks that exact shape, drives the browser OAuth dance, and writes the result back through settings. Neither is useful alone; the value is that both sides now agree on one model.
2The shared model — a tagged union of credentials
The heart of both PRs is one idea: every MCP credential is a variant tagged by a
strategy field. Add OAuth as just another variant and the rest of the
stack — serialization, redaction, UI — handles it uniformly. The SDK and Canvas define the
same set of strategies, in Python and TypeScript.
MCPNoneAuthCredential strategy="none"
MCPApiKeyAuthCredential strategy="api_key"
MCPBearerAuthCredential strategy="bearer"
MCPBasicAuthCredential strategy="basic"
MCPHeaderAuthCredential strategy="header"
MCPOAuthAuthCredential strategy="oauth2"
class MCPServer(_MCPBaseModel):
auth: MCPAuthCredential | None = None
openhands-sdk/openhands/sdk/mcp/config.py
type MCPAuthCredential =
| { strategy: "none" }
| { strategy: "api_key"; value; header_name? }
| { strategy: "bearer"; value }
| { strategy: "basic"; username; password }
| { strategy: "header"; headers }
| { strategy: "oauth2"; /* + state */ };
src/types/mcp-auth.ts
value, password, OAuth tokens) are
SecretStr leaves. They serialize encrypted, and redact to a placeholder when the
settings are read back for display — so an edit never has to round-trip the real secret.3SDK #3964 — before / after
What changed: MCP settings were loosely typed and OAuth wasn't first-class;
legacy shapes were handled by ad-hoc runtime normalization. After, mcp_config is a
typed dict[str, MCPServer], OAuth credentials live under
mcp_config.<server>.auth.state, and the map is normalized to FastMCP/ACP
only at the protocol boundary.
Before
mcp_config: dict[str, MCPServer]
# additionalProperties: true (untyped)
# OAuth: not first-class
# legacy shapes normalized at runtime
# secrets: partial handling
After
mcp_config: dict[str, MCPServer]
# MCPServer.auth: MCPAuthCredential
# OAuth token/client state under auth.state
# legacy shapes handled by settings
# migrations only (no runtime legacy)
# secrets encrypt/redact via settings
New agent-server endpoints drive the OAuth probe and hand the acquired state back to the client to persist:
| Endpoint | Purpose |
|---|---|
POST /api/mcp/oauth/start | Begin an OAuth probe for a server; returns a job id. |
GET /api/mcp/oauth/status/{job_id} | Poll the probe; returns acquired oauth_state when done. |
POST /api/mcp/oauth/callback/{job_id} | Deliver the browser redirect result back to the probe. |
4Canvas #1583 — before / after
What changed: the install flow assumed API-key-shaped servers. After, it
installs OAuth-only and header-based servers as first-class, reads/writes through the SDK's
agent_settings.mcp_config, and normalizes server names to SDK-safe underscore
names. It also folds in two earlier follow-ups (#1569 name normalization + redacted-secret
preservation, #1503 custom headers + editable remote URLs).
Before
Install modal ≈ "paste an API key"
OAuth-only servers: awkward / faked
Custom headers: not supported
Names: not SDK-safe
Edits could drop redacted secrets
After
Install modal: OAuth · header · api_key
OAuth popup → callback → auth.state
Custom request headers supported
Names normalized to underscore form
Edits preserve encrypted/redacted creds
5The OAuth install flow (dynamic registration)
The interesting path is a dynamic OAuth server: the user has no client id/secret, so the server registers one on the fly (PKCE). Canvas opens a popup, the agent-server runs the probe, and the acquired state is written back into settings so the next conversation reuses it with no re-auth.
6Use cases at a glance
| Server kind | Strategy | What the user does |
|---|---|---|
| Public / no auth | none | Install, done. |
| Static-key remote (most marketplace) | api_key / bearer / header | Paste key / headers once. |
| Basic-auth remote | basic | Username + password once. |
| OAuth with client id/secret | oauth2 | Enter client id/secret + scopes; popup consent. |
| Dynamic OAuth (e.g. Superhuman) | oauth2 | Just consent in the popup; client is registered on the fly. |
Grounded to the two PRs above. Both were human-tested by the author against the Superhuman Mail MCP running in the real stack.