Change explainer · two PRs, one story

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.

sdk #3964 — the contract MCPServer + MCPAuthCredential (tagged union) SecretStr encrypt / redact in settings /api/mcp/oauth/start · status · callback canvas #1583 — the consumer Install / edit modal (OAuth, header, api_key) Browser OAuth popup → callback Write agent_settings.mcp_config same shape
Two repos, one credential model. The SDK owns it; Canvas speaks it.

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.

Python · SDK
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
TypeScript · Canvas
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
Secrets (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:

EndpointPurpose
POST /api/mcp/oauth/startBegin 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.
software-agent-sdk#3964 · +5078 / −2036

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
agent-canvas#1583 · +5303 / −787

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.

Canvas UI agent-server MCP + IdP settings POST /api/mcp/oauth/start dynamic client register + PKCE authorize URL → browser popup POST /oauth/callback/{job_id} (redirect result) exchange code → tokens GET /oauth/status → oauth_state write mcp_config.<server>.auth.state (encrypted)
Install once. The acquired OAuth state persists in settings; the next conversation reuses it with no re-authentication.
The SDK PR proves this end to end with a headless integration test: a real FastMCP OAuth-protected server, dynamic client registration, PKCE/token exchange, settings serialization, and a second connection that reuses the persisted credentials.

6Use cases at a glance

Server kindStrategyWhat the user does
Public / no authnoneInstall, done.
Static-key remote (most marketplace)api_key / bearer / headerPaste key / headers once.
Basic-auth remotebasicUsername + password once.
OAuth with client id/secretoauth2Enter client id/secret + scopes; popup consent.
Dynamic OAuth (e.g. Superhuman)oauth2Just 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.