Skip to content

OAuth on mcp.ggui.ai

mcp.ggui.ai implements OAuth 2.1 with PKCE, Dynamic Client Registration (RFC 7591), and the Protected Resource Metadata discovery profile (RFC 9728) so MCP-Apps-aware hosts can connect without a manually-issued client_id.

This page describes the wire format. If you’re a user trying to connect a client (Claude Desktop, claude.ai, Goose, VS Code Copilot), you don’t need any of this — the host handles everything. Read on if you’re building a host, debugging a custom client, or operating an OSS deployment.

Client Server User
│ │ │
│── GET /mcp (no auth) ─────────→│ │
│← 401 + WWW-Authenticate ──────│ │
│ │ │
│── 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 ──────→│── 302 → console.ggui.ai ──────→│
│ │ │── sign in + Approve
│ │← form POST {api_key, params} ─│
│← 302 → redirect_uri?code=… ───│ │
│ │ │
│── POST /oauth/token │ │
│ {code, code_verifier} ──────→│ │
│← { access_token: ggui_user_* }│ │
│ │ │
│── GET /mcp │ │
│ Authorization: Bearer ─→│ │
│← MCP session ─────────────────│ │

The simplest possible bridge between MCP’s OAuth-required client UX and ggui’s existing API-key model:

  • No parallel token table. The access_token returned at /oauth/token is verbatim a ggui_user_* key minted by console.ggui.ai during consent.
  • No translation in the request hot path. Every authenticated /mcp request looks identical to a CLI request — Authorization: Bearer ggui_user_*.
  • No refresh dance. The access token TTL equals the API key TTL. Revoke the key in the console → the client’s next request returns 401. Re-auth means re-running the OAuth ceremony, which is one paste.
  • One audit surface. Every connected client shows up as one row in your keys list, labelled with the client’s client_name from DCR.

The trade-off: there’s no separate “this token came from OAuth” flag. The key works identically whether minted via console UI, the ggui CLI, or the OAuth flow. If that distinction matters for your operator policy, plug in your own OAuthStorage + AuthAdapter (see Custom storage below).

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 — mcp.ggui.ai is both the resource and the auth server.

{
"resource": "https://mcp.ggui.ai/mcp",
"authorization_servers": ["https://mcp.ggui.ai"],
"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://mcp.ggui.ai",
"authorization_endpoint": "https://mcp.ggui.ai/oauth/authorize",
"token_endpoint": "https://mcp.ggui.ai/oauth/token",
"registration_endpoint": "https://mcp.ggui.ai/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.

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.

Terminal window
curl -X POST https://mcp.ggui.ai/oauth/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["http://localhost:33418/callback"],
"client_name": "Claude Desktop"
}'
{
"client_id": "mcp_client_AbCdEf...",
"client_name": "Claude Desktop",
"redirect_uris": ["http://localhost:33418/callback"],
"token_endpoint_auth_method": "none"
}

The user-facing approval entry point. Required query params:

ParamValue
response_typecode
client_idfrom DCR
redirect_urione of the URIs registered with DCR
code_challengebase64url(SHA256(verifier))
code_challenge_methodS256
stateopaque, echoed back on redirect (CSRF defense)
scopemcp (only supported scope today)

When the operator has configured a consentUrl (true on mcp.ggui.ai, where it points at console.ggui.ai/oauth/consent), 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. When no consentUrl is configured (typical OSS posture), the server itself renders an unbranded paste-key HTML form.

Called by the consent UI after the user approves. Same OAuth params echoed as hidden inputs, plus an api_key field carrying the freshly-minted (or pasted) ggui_user_* key. The server validates the key against the same AuthAdapter that gates /mcp, mints a 5-minute auth code, and 302s to the client’s redirect_uri?code=…&state=….

Exchange the auth code for an access token.

Terminal window
curl -X POST https://mcp.ggui.ai/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": "ggui_user_AbCdEf...",
"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 you’re running @ggui-ai/mcp-server yourself and want to mount the OAuth surface, pass oauth: true 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,
});

Auth codes and DCR clients live in OAuthStorage. The default InMemoryOAuthStorage works fine for single-replica dev and any deployment with sticky sessions on the load balancer. For multi-replica deployments without sticky sessions, plug in a Redis or DynamoDB backend — 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.

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.

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.

  • Not refresh-token compatible. The OAuth ceremony is one-time per credential. Add a refresh-token grant only if you also stop using the API key as the access token (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 has no need.
  • Not a replacement for static API keys. If your client can hold a static Authorization: Bearer ggui_user_* header (CLI agents, server-side runtimes), keep doing that — OAuth exists for hosts that need DCR + browser-mediated approval.