OAuth (self-hosted)
read as.md A self-hosted @ggui-ai/mcp-server — enabled with oauth: true on the factory or ggui serve --oauth — implements OAuth 2.1 with PKCE, Dynamic Client Registration (RFC 7591), the Protected Resource Metadata discovery profile (RFC 9728), and the Resource Indicators extension (RFC 8707) for per-app token scoping. MCP-aware hosts can connect with just a server URL — no manually-issued client_id, no shared secret. To onboard a remote host (Claude Desktop, claude.ai, Goose) against your local server, front it with a tunnel (ngrok / cloudflared) and pass --public-base-url=<tunnel-url> so the discovery URLs resolve from the host’s browser.
This page is the wire reference. If you only want to connect a client (Claude Desktop, claude.ai, Goose, VS Code Copilot), the host handles everything — skip this page. Read on if you’re building a host, debugging a custom client, or operating a deployment of @ggui-ai/mcp-server.
The flow at a glance
Section titled “The flow at a glance”Client Server User │ │ │ │── GET /mcp (no auth) ─────────→│ │ │← 401 WWW-Authenticate: │ │ │ Bearer realm="mcp", │ │ │ resource_metadata="<url>" ──│ │ │ │ │ │── GET /.well-known/ │ │ │ oauth-protected-resource ───→│ │ │← { authorization_servers } ───│ │ │ │ │ │── GET /.well-known/ │ │ │ oauth-authorization-server ─→│ │ │← { authorize_endpoint, ... } ─│ │ │ │ │ │── POST /oauth/register ───────→│ (RFC 7591 DCR — no client_secret) │← { client_id } ───────────────│ │ │ │ │ │── open /oauth/authorize ──────→│── in-process consent form ───→│ │ │ │── paste key / pair code + Approve │ │← form POST {api_key, params} ─│ │← 302 → redirect_uri?code=… ───│ │ │ │ │ │── POST /oauth/token │ │ │ {code, code_verifier} ──────→│ │ │← { access_token: <api-key> } ─│ │ │ │ │ │── GET /mcp │ │ │ Authorization: Bearer ─→│ │ │← MCP session ─────────────────│ │The access token IS the API key
Section titled “The access token IS the API key”The simplest possible bridge between MCP’s OAuth-required client UX and ggui’s existing API-key model:
- No parallel token table. The
access_tokenreturned at/oauth/tokenis verbatim the bearer key yourAuthAdapteraccepts on/mcp— pasted (or, withggui serve, exchanged from the terminal pair code) during consent. - No translation in the request hot path. An authenticated
/mcprequest looks identical to a static-bearer request —Authorization: Bearer <api-key>. - No refresh dance. The access token TTL equals the API key TTL. Revoke the key in your store and the client’s next request returns
401. Re-auth is one OAuth round trip — one paste from the user. - One audit surface. Every connected client appears as a single row in your keys list, labelled with the
client_namefrom DCR.
The trade-off: there’s no “this token came from OAuth” flag — the key works identically whether minted by your operator tooling or the OAuth flow. If that distinction matters to your operator policy, plug in your own OAuthStorage + AuthAdapter (see Storage seam below).
Endpoints
Section titled “Endpoints”GET /.well-known/oauth-protected-resource (RFC 9728)
Section titled “GET /.well-known/oauth-protected-resource (RFC 9728)”Tells the client where to find the authorization server. Same origin in our case — your server (here a tunnel exposing the local ggui serve) is both the resource and the auth server.
{ "resource": "https://your-mcp.example.com/mcp", "authorization_servers": ["https://your-mcp.example.com"], "bearer_methods_supported": ["header"], "resource_documentation": "https://modelcontextprotocol.io/extensions/apps/overview"}GET /.well-known/oauth-authorization-server (RFC 8414)
Section titled “GET /.well-known/oauth-authorization-server (RFC 8414)”Authorization-server metadata.
{ "issuer": "https://your-mcp.example.com", "authorization_endpoint": "https://your-mcp.example.com/oauth/authorize", "token_endpoint": "https://your-mcp.example.com/oauth/token", "registration_endpoint": "https://your-mcp.example.com/oauth/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], "scopes_supported": ["mcp"]}token_endpoint_auth_methods_supported: ["none"] is intentional — PKCE is the only supported client authentication, no client_secret is ever issued.
POST /oauth/register (RFC 7591)
Section titled “POST /oauth/register (RFC 7591)”Dynamic Client Registration. Issues a random client_id, no secret. Accepts arbitrary redirect_uris from the client without an allowlist — the trade-off matches the MCP spec’s pragmatism: any client willing to do PKCE + paste-key gets registered.
curl -X POST https://your-mcp.example.com/oauth/register \ -H "Content-Type: application/json" \ -d '{ "redirect_uris": ["http://localhost:33418/callback"], "client_name": "Claude Desktop" }'{ "client_id": "mcp_client_AbCdEf...", "redirect_uris": ["http://localhost:33418/callback"], "grant_types": ["authorization_code"], "response_types": ["code"], "token_endpoint_auth_method": "none", "client_name": "Claude Desktop"}GET /oauth/authorize
Section titled “GET /oauth/authorize”The user-facing approval entry point. Required query params:
| Param | Value |
|---|---|
response_type | code |
client_id | from DCR |
redirect_uri | one of the URIs registered with DCR |
code_challenge | base64url(SHA256(verifier)) |
code_challenge_method | S256 |
state | opaque, echoed back on redirect (CSRF defense) |
scope | mcp (advertised; not gated server-side today) |
resource | optional — RFC 8707 indicator naming the target MCP endpoint (per-app routing); omit for universal scoping |
With no consentUrl configured (the typical self-hosted posture), the server renders an unbranded paste-key HTML form in-process — no external origin needed. When the operator does configure a consentUrl, the server 302s the browser there with every OAuth param forwarded plus an mcp_origin param the consent page uses to know where to POST back. (The hosted endpoint will point consentUrl at its own console — coming soon.)
POST /oauth/authorize (form-encoded)
Section titled “POST /oauth/authorize (form-encoded)”Called by the consent UI (the in-process form by default) after the user approves. Same OAuth params echoed as hidden inputs, plus one of two credential fields:
pair_code— the 6-digit code printed on the terminal banner byggui serve. The server exchanges it throughPairingService.completePairingto mint a per-server bearer. This is the easiest self-hosted path.api_key— a freshly-minted (or pasted) bearer key yourAuthAdapteraccepts.
The server validates the resulting key against the same AuthAdapter that gates /mcp, mints a 5-minute auth code, and 302s to the client’s redirect_uri?code=…&state=….
POST /oauth/token
Section titled “POST /oauth/token”Exchange the auth code for an access token.
curl -X POST https://your-mcp.example.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:33418/callback&client_id=mcp_client_…&code_verifier=ORIGINAL_VERIFIER"{ "access_token": "<api-key>", "token_type": "Bearer", "scope": "mcp"}No refresh_token, no expires_in — the access token’s lifetime is the underlying API key’s lifetime. Re-auth to get a new one.
If the client passed a resource at /authorize and includes one at /token, the two MUST match (RFC 8707 §2.2); mismatch returns 400 invalid_target. Omitting resource at /token is tolerated even when the auth code captured one — RFC 8707 only mandates the constraint when the client opts in.
Resource indicators (RFC 8707)
Section titled “Resource indicators (RFC 8707)”The server supports per-app token scoping via the optional resource query parameter at /oauth/authorize. Two shapes are accepted:
- Universal —
${issuer}(cloud bare root) or${issuer}/mcp(OSS default path). Tokens bind to the user’s full app surface. - Per-app —
${issuer}/apps/<appId>where<appId>matches the deployment’s app-id pattern. The auth code, and any token minted from it, is captured against that single app target. Per-app deployments also surface their own/.well-known/oauth-protected-resourceat the app-scoped path so RFC 9728 discovery resolves correctly when a client starts from the per-app URL.
Absent resource, universal scoping applies. Unknown resources are rejected at /authorize with invalid_target (RFC 8707 §2) before the consent step so the user sees the failure before pasting a key. OSS deployments can plug a validateResource(issuer, resource) callback into the server factory to gate which targets are accepted.
The captured resource is snapshotted onto the DCR client record (lastResource) so an operator console can label rows “Connected to: <App>” rather than “Universal”.
OSS deployment notes
Section titled “OSS deployment notes”Running the CLI? ggui serve --oauth mounts this whole surface — the /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server discovery endpoints plus /oauth/{authorize,token,register}. It’s required for OAuth-discovery hosts (claude.ai / ChatGPT “Add custom connector”); pure-bearer clients work without it. The paste-key form accepts either a ggui_user_* key or the 6-digit pair code from the serve banner.
If you’re embedding @ggui-ai/mcp-server programmatically, pass oauth: true (or an OAuthConfig) to the server factory:
import { createGguiServer } from "@ggui-ai/mcp-server";
const server = createGguiServer({ // ... oauth: { issuerUrl: "https://your-mcp.example.com", consentUrl: "https://your-console.example.com/oauth/consent", // optional storage: new InMemoryOAuthStorage(), // or your own }, auth: yourAuthAdapter,});Storage seam
Section titled “Storage seam”Auth codes and DCR clients live in OAuthStorage. The default InMemoryOAuthStorage is fine for single-replica dev and any deployment with sticky sessions on the load balancer. Multi-replica deployments without sticky sessions need a shared backend — Redis or DynamoDB both work. The interface is two collections (auth codes, DCR clients) with put / consume / get methods; see packages/mcp-server/src/oauth.ts in the public repo for the full type.
Consent UI
Section titled “Consent UI”consentUrl delegates the user-facing approval step to a separate origin. The consent UI sees both the user’s session (whatever auth model it uses) and the freshly-minted API key in plaintext — same trust boundary as the MCP server itself. Validate the mcp_origin param against an allowlist before posting back, otherwise an attacker can craft a URL that exfiltrates a key to a third-party origin.
Without consentUrl, the server renders an in-process paste-key HTML form. Functional but unbranded — fine for OSS deployers who don’t want to stand up a separate origin.
Disable OAuth entirely
Section titled “Disable OAuth entirely”oauth: false (or simply omitting the option) skips the OAuth surface — /oauth/* and the well-known endpoints return 404, and /mcp falls back to the bearer-only AuthAdapter model. Hosts that don’t speak OAuth-DCR can still authenticate by setting a static Authorization: Bearer … header.
Hosted ggui (coming soon)
Section titled “Hosted ggui (coming soon)”The managed endpoint at mcp.ggui.ai will run this identical flow with two deployment-specific choices: consentUrl points at its own console’s consent page (so approval happens on a branded origin that mints a ggui_user_* key for you), and the universal MCP endpoint mounts at the bare root rather than /mcp. Neither is live yet — hosted ggui is not part of GGUI Preview 0.1.0, and nothing on this page depends on it.
Non-goals
Section titled “Non-goals”- Not refresh-token compatible. The OAuth ceremony is one-time per credential. Adding a refresh-token grant requires decoupling the access token from the API key (separate token store + lifetime).
- Not OIDC. No ID tokens, no userinfo endpoint, no nonce. The MCP spec doesn’t ask for them, and the access-token-IS-the-API-key model doesn’t need them.
- Not a replacement for static API keys. If your client can hold a static
Authorization: Bearer <api-key>header (CLI agents, server-side runtimes), keep doing that. For local dev,Authorization: Bearer devworks againstggui serve --dev-allow-all. OAuth exists for hosts that need DCR + browser-mediated approval.