From 3d1a67e5200b0b10a5c1ad71d71fab4ad9c4a9ea Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 11 Aug 2026 01:38:54 +0530 Subject: [PATCH 1/3] docs(mcp): document the remote transport, deprecate stdio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites docs/core/mcp.md for the HTTP surface shipped in authorizerdev/authorizer#757. The page led with `authorizer mcp` (stdio) and described it as stdio-only by design. Both statements are now wrong: the deployable transport is `--mcp-enabled` at POST /mcp, and stdio is deprecated for removal in 2.5.0. It now opens with the difference that actually matters — per-request identity and shared providers, versus one process-wide bearer and a second copy of the whole server — then walks the discovery chain end to end so an operator can follow it with curl. The client-support table is deliberately blunt. Authorizer has no RFC 7591 dynamic client registration, so clients that self-register are not supported and a pre-registered client ID is the path. Claude Code needs both loopback redirect URIs registered because it binds an ephemeral port. Saying so in a table beats someone discovering it from a 401. The "protecting your own MCP server" half is kept — it is still valid and is what someone with their own resource server needs — but demoted, since it is no longer the only way to use MCP with Authorizer. Also adds the two specs behind the work: the transport design and the SDK and examples rollout plan. --- docs/core/mcp.md | 122 ++++- specs/2026-08-10-mcp-http-transport.md | 441 ++++++++++++++++++ specs/2026-08-10-mcp-sdk-and-examples-plan.md | 133 ++++++ 3 files changed, 673 insertions(+), 23 deletions(-) create mode 100644 specs/2026-08-10-mcp-http-transport.md create mode 100644 specs/2026-08-10-mcp-sdk-and-examples-plan.md diff --git a/docs/core/mcp.md b/docs/core/mcp.md index aa1f81a..3a74243 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -18,21 +18,93 @@ permission-aware RAG pattern as the example (see [Real-world recipes → Permission-aware retrieval](./authorization#permission-aware-retrieval-rag--ai-agents)), but driven from inside the model instead of your backend. -## Design & security model - -The MCP server is deliberately minimal and **stdio-only**: - -- **Transport is stdio only.** The host launches `authorizer mcp` as a child process and - talks to it over standard input/output using MCP's JSON-RPC framing. There is **no** - HTTP, SSE, or TCP listener — the server cannot be exposed over the network. This is - enforced in code, not configuration. -- **Only safe tools are exposed.** Credential-issuing operations (`signup`, `login`, - `session`) and destructive ones (`deactivate_account`) are explicitly **not** exposed - as tools. The model can read identity and permissions, never mint tokens or mutate - accounts. -- **Identity comes from a bearer token** you pass at launch — the model never sees a - login form and cannot escalate beyond that token's subject. Permission checks run - through the exact same FGA trust gates as the GraphQL/REST APIs. +## Two ways to run it + +| | **Remote (`--mcp-enabled`)** | **Local (`authorizer mcp`)** | +| --- | --- | --- | +| Transport | Streamable HTTP at `POST /mcp` | stdio subprocess | +| Identity | per request, from the caller's own token | one process-wide `--mcp-bearer` | +| Runs | inside the server you already run | a second process with its own DB pool | +| Status | **use this** | deprecated, removed in 2.5.0 | + +The stdio subcommand still works and still prints a deprecation notice. It cannot be +deployed: it starts a second copy of every provider — storage, memory store, embedded +FGA engine — and serves exactly one user for the lifetime of the process. + +## Remote MCP server + +Enable it on the server you already run: + +```sh +authorizer --url https://auth.example.com --mcp-enabled # ...your other flags +``` + +`--url` is **required** with `--mcp-enabled`, and the server refuses to start without it. +Every token presented at `/mcp` is checked against this deployment's canonical resource +identifier, `/mcp`. Without `--url` that identifier would be derived from request +headers, which would let a caller name the audience their own token has to match — no +check at all. + +### Security model + +- **Every request carries its own token.** No ambient authority, no shared credential. +- **Audience-bound tokens only.** A token is accepted at `/mcp` only when its `aud` is + exactly `/mcp`. An ordinary login token — the kind that works at `/graphql`, + `/v1/*` and gRPC — is rejected here, and an MCP token is rejected there. Neither rule + has an "or" in it: a token you hand to a semi-trusted agent cannot become a full API + credential. +- **Bearer only.** No cookie, no admin secret, and no admin operation reaches this + surface, so it is safe to expose to the public internet and exempt from CSRF. +- **Shared middleware.** Because it is mounted on the main listener, it inherits CORS, + security headers, rate limiting, trusted-proxy handling, request logging and metrics. + +### Discovery + +Authorizer is both the authorization server and the resource server here, so a client +needs nothing configured beyond the URL: + +1. The client calls `POST https://auth.example.com/mcp` with no token. +2. Authorizer answers `401` with + `WWW-Authenticate: Bearer realm="authorizer", resource_metadata="https://auth.example.com/.well-known/oauth-protected-resource/mcp"`. +3. The client fetches that document ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)): + + ```json + { + "resource": "https://auth.example.com/mcp", + "authorization_servers": ["https://auth.example.com"], + "bearer_methods_supported": ["header"], + "scopes_supported": ["openid", "email", "profile", "phone", "offline_access"] + } + ``` + +4. It reads Authorizer's own metadata from `/.well-known/oauth-authorization-server`, + runs the OAuth 2.1 authorization-code flow with PKCE, and passes + `resource=https://auth.example.com/mcp` on both the authorization and token requests + ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) so the issued token is + bound to this server. + +That `resource` value must match **exactly** what a user types when adding the connector, +including the path — give them `https://auth.example.com/mcp`, not the bare origin. + +An expired token gets the same `401`, which is what tells a client to refresh rather than +retry. The audience binding survives refresh, so a rotated token keeps working. + +### Connecting a client + +Authorizer does not yet implement [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) +dynamic client registration, so clients that self-register are not supported. Register a +client once in the dashboard under **Identity → Clients** and hand out its client ID. + +| Client | Supported | How | +| --- | --- | --- | +| **Claude.ai / Desktop / mobile** custom connector | yes | Redirect URI `https://claude.ai/api/mcp/auth_callback`. Paste the client ID under *Advanced settings* when adding the connector. | +| **Claude Code, VS Code** — OAuth | yes | Register `http://localhost/callback` and `http://127.0.0.1/callback`. The port is ignored ([RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3)) because native clients bind an ephemeral one. | +| **Claude Code, VS Code** — static token | yes | Mint a token with `resource=/mcp` and send it as a fixed `Authorization` header. | +| Clients requiring dynamic registration | not yet | Tracked for a future release, together with Client ID Metadata Documents. | + +```sh +claude mcp add --transport http authorizer https://auth.example.com/mcp +``` ## Exposed tools @@ -56,7 +128,12 @@ example, `check_permissions` accepts: } ``` -## Running the server +## Local stdio server (deprecated) + +Kept working for existing setups, with a deprecation notice on every run. Prefer +`--mcp-enabled` above. + +### Running the server ```bash authorizer mcp \ @@ -78,7 +155,7 @@ you want FGA on a separate store; `--fga-store` takes one of `sqlite`, The `mcp` command inherits the root server flags (database, JWT, client-id, `--fga-store`, etc.) so it can resolve identity and run the FGA engine in-process. -### MCP-specific flags +#### MCP-specific flags | Flag | Description | Required | | ----------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------- | @@ -88,7 +165,7 @@ etc.) so it can resolve identity and run the FGA engine in-process. > Logging goes to **stderr** only — `stdout` is reserved for the MCP JSON-RPC stream, so > never print to it. -## Connecting a host +### Connecting a host Most MCP hosts read a JSON config that declares the command to spawn. For **Claude Desktop** (`claude_desktop_config.json`) or **Claude Code** @@ -126,11 +203,10 @@ Typical messages mirror the gRPC status: `Unauthenticated`, `PermissionDenied`, ## Authorizer as the authorization server protecting *your own* MCP server -Everything above is about the **built-in stdio server** — Authorizer's own tools, -consumed by a host on your machine. The other direction is just as common: your MCP -server (streamable HTTP, hosted anywhere) needs a real OAuth 2.1 authorization server -in front of it, and Authorizer can be that AS. This is a **different pattern** — -plain OAuth, no `authorizer mcp` involved: +Everything above is about Authorizer's **own** MCP surface. The other direction is just +as common: your MCP server, hosted anywhere, needs a real OAuth 2.1 authorization server +in front of it, and Authorizer can be that AS. Same specs, different division of labour — +there, you implement the resource-server half: | Spec | What it says | Who implements it | | --- | --- | --- | diff --git a/specs/2026-08-10-mcp-http-transport.md b/specs/2026-08-10-mcp-http-transport.md new file mode 100644 index 0000000..782cdfd --- /dev/null +++ b/specs/2026-08-10-mcp-http-transport.md @@ -0,0 +1,441 @@ +# Remote MCP transport — Authorizer as an OAuth 2.1 resource server + +**Status**: planned for 2.4.0 (currently rc.18). PR 1 merged to branch, reviewed and amended. +**Owner**: principal-engineer +**Security review**: required on PR 1 and PR 2 + +--- + +## 1. Problem + +MCP today is `authorizer mcp` (`cmd/mcp.go`): a second process, stdio only. + +Two properties make it undeployable: + +1. **It re-instantiates the entire server.** Storage, memory store, token, email, SMS, + audit, events, the embedded FGA engine, authenticators, webauthn, *and a second + `grpcsrv.Server`* (`cmd/mcp.go:89-182`). Running MCP means a second DB pool, a + second Redis connection and a second FGA engine. +2. **Identity is one static `--mcp-bearer` per process** (`internal/mcp/server.go:50`). + One process serves exactly one user, forever. + +`internal/mcp/server.go:26-36` documents stdio-only as deliberate, guarded by +`TestServer_StdioOnly`, and states its own exit condition: *"To deliberately add a +new transport: implement an auth+rate-limit interceptor for MCP first, then update +this test's allow-list."* This spec satisfies that precondition. + +**Release window.** Stdio MCP has only ever shipped in release candidates, never in +a stable release. Replacing it before 2.4.0 GA breaks no compatibility promise; +after GA it becomes a breaking change that must wait for 2.5.0. + +## 2. Decision: path-mount `/mcp` on the main HTTP listener + +Rejected: a dedicated port alongside gRPC (`9091`) and metrics (`8081`). Those two +are separate for reasons MCP does not share. + +| Listener | Why separate | +|---|---| +| gRPC | HTTP/2 wire protocol gin cannot route | +| metrics | must **not** be publicly exposed; binds `127.0.0.1` by default | +| MCP | plain HTTP/1.1 JSON-RPC that **must** be publicly reachable, on the same origin as the AS metadata | + +A separate port costs: + +1. **Discovery breaks.** The canonical resource URI becomes + `https://auth.example.com:9092/mcp` — a different origin from the authorization + server. Several MCP clients resolve `.well-known` against the root authority only. +2. **A second ingress + TLS cert + hostname in every deployment.** Authorizer is one + binary behind one ingress today. +3. **The middleware stack gets re-implemented.** Mounting on the gin router inherits + CORS, security headers, rate limiting, trusted proxies, logging, metrics and + graceful shutdown — which is exactly the "no auth/rate-limit interceptors" + objection recorded in the stdio-only comment. + +Precedent: `mcp.sentry.dev/mcp`, `api.githubcopilot.com/mcp`; Linear, Atlassian, +Stripe and Cloudflare all serve remote MCP at a path on an HTTPS host. + +## 3. Scope + +**In 2.4.0**: `POST /mcp` as an OAuth 2.1 resource server, opt-in via `--mcp-enabled`; +RFC 9728 protected resource metadata; RFC 8707 audience validation; per-request +caller identity; RFC 8252 loopback redirect matching. Client onboarding via +**pre-registered OAuth clients** (§8), which needs no new endpoint. `authorizer mcp` +keeps working with a deprecation notice. + +**Deferred to 2.5.0**: CIMD (Client ID Metadata Documents) for zero-touch client +onboarding, and the `/authorize` consent screen that CIMD requires (§8.4); per-tool +scope gating; MCP audit events; SSE / stateful sessions; MCP resources and prompts; +delegated (RFC 8693) tokens at `/mcp` (§7). + +**Not planned**: RFC 7591 dynamic client registration — see §8.3 for why the vendor +documentation argues against it. + +## 4. Architecture + +### 4.1 Audience scoping is structural + +`ValidateAccessToken` rejects any token whose `aud` is an absolute URI +(`internal/token/auth_token.go`) — and an MCP token's `aud` is *required* to be a +URI. The naive fix, adding an MCP branch to the shared resolver, would make a token +minted for `resource=/mcp` valid at `/graphql` as well, defeating the audience +binding that makes the surface safe. + +Instead, MCP gets its **own bufconn-only `grpcsrv.Server`**, built from the same +providers, never bound to TCP, whose auth interceptor uses an MCP-specific +`TokenResolver`. The public gRPC / REST / GraphQL path is untouched. + +Three things make that boundary real rather than asserted. The first shipped with +the design; the other two came out of review, where the original claim of +"structural" turned out to hold only by coincidence: + +1. **The resolver is per-server, not per-request.** A token can only be accepted + where its audience says it belongs, because the two servers are different objects. +2. **A non-nil resolver is the interceptor's SOLE authority.** It also disables the + two paths that authenticate without consulting a resolver at all: the super-admin + check (admin cookie / `x-authorizer-admin-secret`) and the `Session` RPC's + cookie-only branch. `transport.MetaFromGRPC` reconstructs cookies from gRPC + metadata, so leaving those active meant a browser session was one proto + annotation away from authenticating a tool call on a CSRF-exempt endpoint. + Pinned by `TestAuth_SoleAuthorityDisablesCookieAndAdminSecretPaths`, including + the inverse case that default servers are unaffected. +3. **`public` methods are kept out of the gap.** The interceptor attaches no + principal for a method marked `public`, and the service layer then resolves the + caller itself with the DEFAULT rule (`service.callerTokenData`, + `resolveFgaCaller`). A method that is both `mcp_tool.exposed` and `public` *and* + identity-resolving would therefore bypass the MCP audience rule entirely. + `TestExposedMCPToolsCannotBypassTheMCPTokenRule` (`internal/mcp`) keeps that + intersection empty. Today the exposed set is `Profile`, `Meta`, + `CheckPermissions`, `ListPermissions`; only `Meta` is `public`, and it resolves + no caller. Adding a name to that test's allow-list is a security assertion, not + a formality. + +``` +POST /mcp ──► gin (CORS, sec headers, rate limit, logging) + └► StreamableHTTPHandler (stateless, JSON response) + └► tool dispatch ──bufconn──► MCP grpc.Server + └► interceptors.Auth(TokenResolver = MCP) + └► ValidateMCPAccessToken(aud == /mcp) +``` + +### 4.2 One normalizer for the canonical origin + +`Config.CanonicalURL()` reduces `--url` to scheme+host, stripping path, query, +fragment, userinfo and any trailing slash — matching `parsers.SetTrustedURL` +exactly. `Config.MCPResource()` is `CanonicalURL() + "/mcp"`. + +Everything self-referential derives from that one function: the PRM `resource`, the +PRM `authorization_servers` and `jwks_uri`, and the `aud` comparison in +`ValidateMCPAccessToken`. `parsers.GetHost` applies the same normalization, and it +is the normalized value that becomes every token's `iss`. + +This is not hypothetical tidiness. Review caught the metadata document building its +issuer from the RAW `--url` while `resource` was normalized: an operator running +`--url https://auth.example.com/auth` passed startup and published an authorization +server and a `jwks_uri` that both 404, against tokens whose `iss` was +`https://auth.example.com`. A discovery chain that dead-ends with no error anywhere. +`TestProtectedResourceMetadataNormalizesIssuer` covers the path and trailing-slash +cases; the config test asserts `CanonicalURL() + "/mcp" == MCPResource()`. + +The canonical origin is derived from `--url` and never from a request. +`parsers.GetHost` falls back to request headers when `--url` is unset, and an +audience check against a header the caller controls authenticates anyone. So +`--mcp-enabled` **hard-fails at startup without `--url`**. + +### 4.3 Discovery: one metadata path + +RFC 9728 §3.1 builds the metadata URL by inserting the well-known segment between +the host and the resource identifier's **path**, so +`/.well-known/oauth-protected-resource/mcp` is the URL that denotes `/mcp`. The +bare `/.well-known/oauth-protected-resource` denotes the origin, and §3.3 requires a +client to reject a document whose `resource` is not identical to the identifier it +used to build the request. + +So the document is served at **one** path. Serving it at both would hand strict +clients a mismatch to reject. Discovery reaches it the way §5.1 intends: the `401` +from `/mcp` carries `WWW-Authenticate: Bearer resource_metadata=""`, which +is the primary mechanism; well-known probing is the fallback. Anthropic documents +Claude probing the path-inserted form first, so Claude is unaffected either way. + +`scopes_supported` is shared with the authorization server metadata via one +`supportedScopes` var. A protected-resource document that omitted `offline_access` +while the AS advertised it would send clients into a flow that returns no refresh +token, and the agent's session would die at access-token expiry. + +### 4.4 Token passthrough + +Forwarding the caller's bearer across the in-process bufconn hop is **not** the +pattern the MCP spec forbids. That prohibition covers forwarding a client's token to +an *upstream third-party API*. Here the resource server and the "downstream" are the +same process, same issuer, same trust domain — identical to what the REST gateway +already does with `grpcgateway-authorization`. + +### 4.5 Stateless transport + +`mcp.NewStreamableHTTPHandler` with `Stateless: true, JSONResponse: true`. + +The main listener sets `WriteTimeout: 60s` (`internal/server/server.go:98`), which +would kill a long-lived SSE stream. In stateless mode the SDK returns `405` + `Allow` +on GET (`streamable.go:335-340`), which is spec-conformant. Every exposed tool is +request/response, so nothing is lost, and horizontal scaling needs no sticky sessions. + +## 5. Work breakdown + +### PR 1 — `security/mcp-resource-server` ✅ done + +No behaviour change to any existing surface. + +| File | Change | +|---|---| +| `internal/token/auth_token.go` | Extracted `validateStatefulAccessToken`, parameterized by an audience policy and a subject-liveness policy. `ValidateAccessToken` keeps today's rules verbatim. | +| `internal/token/mcp_access_token.go` | `ValidateMCPAccessToken(gc, token, resource)`. Same stateful core; audience inverted to `sameAudience(aud, resource)`; subject liveness via `subjectIsLive` (§6). | +| `internal/grpcsrv/interceptors/auth.go` | `TokenResolver` override at both identity-resolution sites; a non-nil resolver becomes the sole authority (§4.1). | +| `internal/grpcsrv/interceptors/mcp_auth.go` | `MCPTokenResolver(tp, resource)` — bearer only, no cookie fallback. | +| `internal/config/mcp.go` | `CanonicalURL()` + `MCPResource()` (§4.2). | +| `internal/http_handlers/protected_resource.go` | RFC 9728 document, all URLs from `CanonicalURL()`. | +| `internal/http_handlers/openid_config.go` | `supportedScopes` shared by both discovery documents. | +| `internal/server/http_routes.go` | One PRM route, registered only when `--mcp-enabled`. | +| `cmd/root.go` | `--mcp-enabled` (default `false`); hard-fail without `--url`. | + +Review found and fixed: the raw-`--url` issuer (§4.2), the two resolver-bypassing +auth paths and the `public`-method gap (§4.1), the two-path PRM (§4.3), the +divergent `scopes_supported`, `MCPTokenResolver` shipping untested, and the audience +test asserting one layer below the real entry point. + +### PR 2 — `feat/mcp-http-transport` + +| File | Change | +|---|---| +| `internal/mcp/server.go` | Drop the static `bearer` / `authorizerURL` fields. `stampAuth` reads the caller's `Authorization` from `req.Extra.Header` (SDK `shared.go:487`) per call. Add `Handler() http.Handler`. | +| `internal/server/http_routes.go` | `router.Any("/mcp", ...)` when enabled; `401` + `WWW-Authenticate: Bearer resource_metadata="…"` on unauthenticated calls. | +| `internal/http_handlers/csrf.go` | Exempt `/mcp` beside the existing `/scim/v2/` — bearer-authenticated, cookieless. | +| `internal/http_handlers/authorize.go` | RFC 8252 §7.3 loopback redirect matching (§8.5). | +| `cmd/root.go` | Build the bufconn MCP server from the already-wired providers. | +| `internal/mcp/transport_test.go` | Replace `TestServer_StdioOnly` per its own exit condition. | + +With `--url` mandatory, `parsers.GetHost` short-circuits to `trustedURL`, so the +`x-authorizer-url` stamping dance is unnecessary — the bridge forwards only +`Authorization`. + +Carried over from PR 1 review, to be done against a working endpoint: + +- Extract one `token.SessionDataFromClaims(claims)` helper; `MCPTokenResolver` + currently hand-copies the claims mapping from `auth_token.go`, so a new field on + `SessionOrAccessTokenData` would silently reach MCP as a zero value. +- `subjectIsLive` always tries `GetUserByID` before `GetClientByID`, so machine + tokens — the dominant MCP caller — pay two serial DB reads. The claims carry + `login_method`, so the order can be chosen. Touches the delegated path too, so + measure before changing. + +**Open item:** the `Session` RPC is `exposed: false`, so it is unreachable as an MCP +tool, and the sole-authority guard now disables its cookie branch anyway. No action +needed unless it is ever exposed. + +### PR 2a — `security/machine-token-liveness` + +Separate commit so it can be reverted independently. See §6. This is the only part +of the plan that is **not** backward compatible, deliberately. + +### PR 3 — `docs/mcp-http` + +```go +mcpCmd.Deprecated = "the stdio MCP transport will be removed in 2.5.0. " + + "Run the server with --mcp-enabled and connect to POST /mcp instead. " + + "See https://docs.authorizer.dev/core/mcp" +``` + +Plus one `log.Warn()` in `runMCP` so it lands in logs, not only the terminal. + +- `authorizer-docs/docs/core/mcp.md` — rewrite. `## Design & security model` and + `## Running the server` are now wrong. Must include the client-onboarding table + from §8.2 and the exact resource URL operators hand to users (§8.6). +- `docs/core/oauth2-oidc.md`, `docs/core/endpoints.md` — add the PRM endpoint. +- `README.md:75` — MCP is a server transport, not a CLI subcommand. +- `ROADMAP_V2.md` §4.1 — mark MCP resource-server + RFC 9728 delivered; leave CIMD + and consent open. +- `CHANGELOG.md` — Added + Deprecated entries. + +## 6. Service-account liveness (PR 2a) + +`userIsRevoked` (`auth_token.go`) looks the subject up **as a user only** and returns +`false` when it finds nothing — so a machine token, whose `sub` is `schemas.Client.ID`, +reports "not revoked" and keeps working after the service account is deactivated. +`subjectIsLive` (`delegated_access_token.go`) already resolves user-then-client and +fails closed. + +PR 1 uses `subjectIsLive` for the MCP path only. PR 2a points the shared core at it +too, which changes GraphQL, gRPC and REST: + +- A deactivated service account's `client_credentials` token stops working. That is + the fix. +- Fail-closed when the subject resolves to neither a user nor a client. Today that + fails open; it matters as a fallback, since `DeleteUser` purges sessions via + `asyncutil.Go` best-effort (`admin_users.go:483`). +- No change for any live user or active service account. One extra `GetClientByID` + read, only on machine-token validation. + +**Do it before GA.** Service accounts, `client_credentials` and workload identity are +all new in 2.4.0 (#641–#647, #654), so there is no installed base. Fixed now it is a +bug that never shipped; fixed in 2.5.0 it is a behaviour change to a GA'd feature. +Industry norm is looser — Auth0, Okta and Keycloak treat `client_credentials` tokens +as non-revocable before expiry — but a check that *looks* like it enforces and +silently does not is worse than no check. + +## 7. Deferred: delegated tokens at `/mcp` + +An RFC 8693 delegated token bound to `resource=/mcp` fails both validators +today: the stateful path because delegated tokens carry no nonce and no session +entry, and `ValidateDelegatedAccessToken` because it requires `aud` to equal the bare +host, not `/mcp`. + +This is a **decision, not an oversight**. The delegated path is the weaker one (no +byte-for-byte comparison against a stored token), and its doc comment states that +widening it must be a deliberate edit to that function. The strong path ships first. + +## 8. Client onboarding + +### 8.1 What Claude actually requires + +From [Anthropic's connector authentication docs](https://claude.com/docs/connectors/building/authentication), +the supported types are `oauth_dcr`, `oauth_cimd`, `oauth_anthropic_creds`, +`custom_connection`, `static_headers` (beta), and `none`. + +> "Supplying your own pre-registered client ID (and secret, if your server requires +> one) as static client credentials is a good option when you want a stable OAuth +> client per organization: **it avoids dynamic client registration entirely**, and +> the credentials are scoped to the organization that entered them." + +Custom connectors take an OAuth Client ID under **Advanced settings**; the Client +Secret is optional. So DCR is **not** required for claude.ai remote connectors. + +### 8.2 Compatibility with what 2.4.0 ships + +| Client | Works | How | +|---|---|---| +| claude.ai / Desktop / mobile custom connector | yes | Operator creates a client at `/identity/clients`, redirect URI `https://claude.ai/api/mcp/auth_callback`, pastes the client ID into Advanced settings | +| Claude Code, VS Code — static header | yes | Token from `/oauth/token` with `resource=/mcp`; `client_credentials` + `resource` already supported (`token.go:293`) | +| Claude Code — OAuth | yes, with the §8.5 fix | RFC 8252 loopback redirect, ephemeral port | +| Directory listing / zero-touch onboarding | 2.5.0 | Needs CIMD (§8.4) | + +Note: a pure machine-to-machine `client_credentials` grant is **not supported** by +Claude connectors — "Every connection requires user consent." The `client_credentials` +path above is only for the static-header mode, where the operator mints the token +themselves. + +### 8.3 Why not DCR + +Anthropic's own guidance argues against it: + +> "For servers expecting high traffic from the directory, **prefer CIMD or +> `oauth_anthropic_creds` over DCR. DCR causes Claude to register a new client on +> every fresh connection**, which can result in very large numbers of registered +> clients on your authorization server." + +That is unbounded row growth from *normal traffic*, in every operator's deployment, +in exchange for an open unauthenticated write endpoint. Keycloak and Ory Hydra both +gate registration behind an initial access token or registration policies for the +same reason. + +### 8.4 CIMD is the 2.5.0 path, and it needs consent + +[Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) +(MCP spec 2025-11-25) make the `client_id` a URL the authorization server fetches — +no registration endpoint, no write path, no row growth. Claude selects CIMD when the +AS metadata advertises both `"client_id_metadata_document_supported": true` and +`"none"` in `token_endpoint_auth_methods_supported`. Claude Code already uses it. + +CIMD makes client identity **self-asserted**, which is exactly when a consent screen +becomes load-bearing — the MCP spec requires the authorization server to display the +redirect URI hostname on consent, with an extra warning for loopback-only clients. +Authorizer has no consent screen today: `prompt=consent` is accepted and ignored +(`authorize.go:142-145`), so with a live session `/authorize` issues a code silently. + +That is safe for 2.4.0 because every client is operator-created — the trust decision +is the operator's, the same posture every first-party client has today. It stops +being safe the moment a client can assert its own identity. **CIMD and consent land +together in 2.5.0, or neither does.** + +Consent needs no persistence: `/authorize` is hit once per connector setup, and +refreshes go to `/oauth/token`. `web/templates/logout_confirm.tmpl` is the existing +server-rendered confirmation pattern to copy — no SPA build, no schema change across +13 providers. + +### 8.5 RFC 8252 loopback redirect matching (PR 2) + +Claude Code uses an RFC 8252 loopback redirect on an **ephemeral port**, declares +`http://localhost/callback` and `http://127.0.0.1/callback` in its CIMD, and requires +that *"your authorization server must accept both with the port component ignored."* + +`authorize.go:166-182` does exact string matching against registered redirect URIs, +so `http://localhost:3118/callback` never matches. [RFC 8252 +§7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3) mandates +port-agnostic matching for the IP-literal form regardless of MCP, so this is a +compliance gap for every native app. + +Scope the change to loopback hosts, and only when the *registered* URI is itself +loopback. It only widens — nothing previously accepted becomes rejected — but it is a +loosening of a security-critical check and should be reviewed as one. + +### 8.6 Verified already correct + +- `code_challenge_methods_supported` is advertised (`openid_config.go`). +- Refresh-token rotation with reuse detection exists (`token.go`, `ErrRefreshTokenReuse`). +- The PRM path matches Claude's documented probe order. + +## 9. Open items + +- **No per-client gate on the MCP resource.** `/authorize` accepts any absolute URI + as `resource`, so any client a user can log in through can request + `resource=/mcp`. Tools still enforce their own FGA/principal checks, but MCP + itself has no client-level gate. Either an allow-list of clients permitted to + request the MCP resource, or bring per-tool scope gating forward. Decide in PR 2. +- **`sameAudience` is case-sensitive on the host.** It tolerates a trailing slash, but + `https://AUTH.example.com/mcp` would not match. The MCP spec says servers SHOULD + accept uppercase scheme/host for robustness, and Anthropic requires the PRM + `resource` to match the URL "exactly as the user enters it in Claude". Low + likelihood — clients echo the `resource` from our document — but the docs must tell + operators the exact URL to hand out. +- **`--url` should become required.** Its own flag help says leaving it unset + "exposes host-header-injection account takeover (CWE-640)". Keycloak requires + `--hostname` in its production profile, Ory Hydra requires `urls.self.issuer`, Dex + requires `issuer`. Recommendation: startup `WARN` in 2.4.0, required in 2.5.0 with + a documented escape hatch for multi-tenant reverse-proxy deployments. Not a hard + break in 2.4.0 — the release already carries one (`--encryption-key`). +- **Verification bar.** For any auth-path change, revert the fix and watch the new + test fail before restoring. Build, vet, lint and 42 packages of tests were all + green on a half-wired security seam in PR 1; only the revert-and-fail step proves + a test tests anything. Worth adding to `AGENTS.md`. + +## 10. Test plan + +Both directions of the audience boundary must be pinned: + +- MCP-audience token **rejected** by the regular public path — asserted at + `GetUserIDFromSessionOrAccessToken`, the entry point gRPC/REST/GraphQL actually + use, not only at `ValidateAccessToken` (which the delegated fallback sits behind). +- `client_id`-audience token **rejected** by `ValidateMCPAccessToken`. +- Empty `aud`, bare-host `aud`, foreign-resource `aud`, expired, revoked session, + deactivated service account, wrong `token_type`. +- PRM document correct at the §3.1 path, including `offline_access` in + `scopes_supported`; issuer and `jwks_uri` normalized for path and trailing-slash + `--url`; unaffected by a hostile `Host` / `X-Authorizer-URL`. +- Every `mcp_tool.exposed` method is non-`public` or explicitly identity-free. +- A custom `TokenResolver` is used at every identity-resolution site, and the cookie + and admin-secret paths are disabled — plus the inverse, that default servers keep + both. +- `/mcp` unauthenticated → `401` + `WWW-Authenticate: Bearer resource_metadata=…`; + `GET /mcp` → `405`; `--mcp-enabled=false` → `404`. +- `internal/e2e/smoke_test.go` gains the HTTP MCP surface; the stdio subprocess test + stays while the command exists. + +Verification bar (`AGENTS.md`): `go build ./...` → `go vet ./...` → `make test` → +`make lint`, plus `make smoke` before cutting rc.19. No storage changes, so no +cross-DB run needed. + +## 11. References + +- [MCP Authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) +- [Anthropic — Authentication for connectors](https://claude.com/docs/connectors/building/authentication) +- [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [RFC 8707 — Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707.html) +- [RFC 8252 — OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252) +- [Keycloak — Configuring the hostname](https://www.keycloak.org/server/hostname) diff --git a/specs/2026-08-10-mcp-sdk-and-examples-plan.md b/specs/2026-08-10-mcp-sdk-and-examples-plan.md new file mode 100644 index 0000000..41a32ad --- /dev/null +++ b/specs/2026-08-10-mcp-sdk-and-examples-plan.md @@ -0,0 +1,133 @@ +# MCP rollout — SDK and examples changes + +Companion to `2026-08-10-mcp-http-transport.md`. Server work is PR +authorizerdev/authorizer#757; this is what has to follow it, and in what order. + +**Status**: plan only, nothing implemented. + +--- + +## 1. What actually changed for a client + +Three server changes have client-visible consequences. Everything below follows +from them. + +| Change | Consequence for a client | +|---|---| +| `POST /mcp` exists, OAuth-protected | A client can talk MCP to Authorizer, but only with a token whose `aud` is `/mcp` | +| RFC 8707 `resource` now survives the login redirect and token refresh | Requesting a resource-bound token actually works end to end; SDKs need a way to ask for one | +| A refresh naming a *different* resource is rejected with `invalid_target` | An SDK that sets `resource` on refresh must send the same value it was granted, or omit it | + +The last one is the only **breaking** change, and only for callers who were +already passing a mismatched `resource` on refresh — previously ignored, now a +`400`. That was never a working configuration, but it did not error before. + +## 2. SDK changes + +### 2.1 `authorizer-js` — required + +`authorize()` (`src/index.ts:113`) builds the `/authorize` query from a fixed +set of parameters and has **no way to pass `resource`**. A browser client +therefore cannot obtain an MCP-bound token at all. + +- Add `resource?: string` to `Types.AuthorizeRequest`, forwarded to the + `/authorize` query when set. +- Forward the same value on the code→token exchange, since the token endpoint + requires the echoed `resource` to match the one bound to the code. +- Do **not** send it on refresh unless the caller asked for it. The server now + carries the binding itself, and sending a stale or guessed value turns a + working refresh into `invalid_target`. +- Types are hand-maintained here (see the release runbook's note on why + `authorizer-js` stays hand-typed), so the type addition is manual. + +### 2.2 `authorizer-go` — small + +`GetTokenRequest.Resource` already exists (`get_token.go:37`) and is sent +(`get_token.go:101`), so the authorization-code path works today. + +- Audit the refresh path for the same "don't send unless asked" rule as above. +- Add a `Resource` option wherever the SDK builds an authorization URL, matching + the JS change, so both SDKs can request an MCP-bound token. + +### 2.3 `authorizer-react` — none expected + +It delegates to `authorizer-js`; the change surfaces automatically once 2.1 +lands. Verify the `authorize()` passthrough does not strip unknown fields. + +### 2.4 `authorizer-python` — audit only + +Confirm whether its token request exposes `resource`. Same rules if it does. + +### 2.5 Not affected + +`authorizer-flutter-sdk`, `authorizer-svelte`, `authorizer-vue` — no OAuth +parameter surface of their own. + +## 3. Examples + +### 3.1 `with-mcp` — needs the biggest rewrite + +Today it demonstrates the **inverse** pattern: a ~150-line Express MCP server +that Authorizer protects as an external resource server, plus a bonus section on +the stdio subcommand. Both framings are now partly wrong. + +- Lead with Authorizer's **own** MCP server: `--mcp-enabled`, the discovery + chain, connecting Claude Code and a claude.ai custom connector. +- Keep the external-resource-server walkthrough — it is still valid and is what + someone protecting *their* MCP server needs — but demote it to the second half + and stop describing the stdio subcommand as the built-in option. +- Its client walkthrough currently ends at `client_credentials` being rejected + for wrong `aud` and then uses token exchange. Worth adding the plain + authorization-code path with `resource`, which is what a real MCP client does. + +### 3.2 `with-claude-agents` — needs updating + +Almost certainly wires `claude mcp add` against the stdio subcommand. Should +become `claude mcp add --transport http authorizer https://auth.example.com/mcp` +with a note about registering the loopback redirect URIs. + +### 3.3 Verify, likely no change + +`with-agent-delegation`, `with-agent-permissions`, `with-a2a-agent-card`, +`with-agents-python`, `with-m2m-client-credentials` — these exercise token +exchange and `client_credentials`, which are unchanged. Check only that none of +them sets `resource` on a refresh call. + +### 3.4 New example, optional + +A minimal "connect Claude to your Authorizer" walkthrough would carry more +weight than docs alone, since the client-side configuration (which redirect URI, +which client ID, the exact resource URL) is where people will get stuck. + +## 4. Docs + +`authorizer-docs/docs/core/mcp.md` is already rewritten in the working tree and +needs its own PR — separate repo. It documents the discovery chain, the client +compatibility matrix, and the absence of dynamic client registration. + +Also worth a pass: `docs/core/oauth2-oidc.md` should mention that `resource` +survives refresh, since that is now a guarantee clients can rely on. + +## 5. Order + +1. **Server PR merges** (#757). Nothing below is testable before it. +2. **`authorizer-js`** — the only hard blocker for a browser client. +3. **`authorizer-docs`** — so the client instructions exist before examples cite + them. +4. **`with-mcp` + `with-claude-agents`** — the two examples that are actively + misleading once the server ships. +5. **`authorizer-go`**, python audit, remaining example verification. + +Per the release runbook the server releases first and SDKs follow, so steps 2 +and 5 are SDK releases in their own right, not part of the server tag. + +## 6. Open question for the SDKs + +Neither SDK has an opinion about **which** resource to request. A client +targeting Authorizer's own MCP server needs `/mcp`, which the SDK +already knows. Worth deciding whether `authorize({ resource: 'mcp' })` should be +sugar for that, or whether callers always pass the full URI. Sugar is friendlier +and removes a class of typo that produces a permanent, hard-to-diagnose 401; a +literal URI is more honest about what is being requested. My inclination is the +literal URI, with the docs showing the exact string, because the same parameter +is used for third-party resource servers where no sugar is possible. From 4cac50d30f1b2eb07a11b92bd7ae741ba994cc79 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 11 Aug 2026 02:04:25 +0530 Subject: [PATCH 2/3] docs(mcp): correct the client-support table after testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested against Claude Code 2.1.226. The OAuth path does NOT work: the client refuses with "Incompatible auth server: does not support dynamic client registration" and does not fall back to a manually-supplied client id. The earlier table claimed it worked once RFC 8252 loopback matching landed, which was an inference from Anthropic's docs rather than an observation. What does work, verified end to end: a static bearer token bound to /mcp. claude mcp list reports Connected, and tools/list and tools/call both function. The claude.ai custom-connector row is downgraded to unverified for the same reason — it rests on the same inference that just failed for Claude Code. --- docs/core/mcp.md | 43 +++++++++++++++---- specs/2026-08-10-mcp-sdk-and-examples-plan.md | 20 +++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/core/mcp.md b/docs/core/mcp.md index 3a74243..6b307e4 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -91,21 +91,46 @@ retry. The audience binding survives refresh, so a rotated token keeps working. ### Connecting a client -Authorizer does not yet implement [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) -dynamic client registration, so clients that self-register are not supported. Register a -client once in the dashboard under **Identity → Clients** and hand out its client ID. +**Verified against a real Claude Code client**, so this table says what actually +happens rather than what the specs allow. -| Client | Supported | How | +| Client | Works | How | | --- | --- | --- | -| **Claude.ai / Desktop / mobile** custom connector | yes | Redirect URI `https://claude.ai/api/mcp/auth_callback`. Paste the client ID under *Advanced settings* when adding the connector. | -| **Claude Code, VS Code** — OAuth | yes | Register `http://localhost/callback` and `http://127.0.0.1/callback`. The port is ignored ([RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3)) because native clients bind an ephemeral one. | -| **Claude Code, VS Code** — static token | yes | Mint a token with `resource=/mcp` and send it as a fixed `Authorization` header. | -| Clients requiring dynamic registration | not yet | Tracked for a future release, together with Client ID Metadata Documents. | +| **Claude Code, VS Code** — static token | **yes, verified** | Mint a token bound to `/mcp` and pass it as a fixed header (below) | +| **Claude Code** — OAuth | **no** | Claude Code refuses: *"Incompatible auth server: does not support dynamic client registration"* | +| **Claude.ai custom connector** — pasted client ID | unverified | Anthropic documents an OAuth Client ID field under *Advanced settings*; not confirmed here | +| Any client that needs to self-register | no | Needs RFC 7591 DCR or a Client ID Metadata Document; Authorizer has neither yet | + +Authorizer does not implement RFC 7591 dynamic client registration, and Claude +Code will not fall back to anything else — it refuses the server outright rather +than prompting for a client ID. Until DCR or CIMD lands, **the static-token path +is the supported way to connect Claude Code.** ```sh -claude mcp add --transport http authorizer https://auth.example.com/mcp +# 1. Create a service account: dashboard → Identity → Clients (note the id + secret) +# 2. Mint a token bound to the MCP resource +curl -s -X POST https://auth.example.com/oauth/token \ + -d grant_type=client_credentials \ + -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \ + -d scope=openid \ + -d resource=https://auth.example.com/mcp + +# 3. Register it +claude mcp add --transport http authorizer https://auth.example.com/mcp \ + --header "Authorization: Bearer $ACCESS_TOKEN" ``` +`claude mcp list` should then report **✔ Connected**. + +The `resource` parameter is the part people miss: without it the token's audience +is the client id, and `/mcp` rejects it. That is the audience binding working, not +a bug. + +Note this token identifies the *service account*, not a human — `profile` returns +nothing useful and permission checks resolve to `service_account:`. For +per-user identity you need the OAuth flow, which is why DCR/CIMD support matters +and is tracked for a future release. + ## Exposed tools | Tool | Auth required | Description | diff --git a/specs/2026-08-10-mcp-sdk-and-examples-plan.md b/specs/2026-08-10-mcp-sdk-and-examples-plan.md index 41a32ad..057de23 100644 --- a/specs/2026-08-10-mcp-sdk-and-examples-plan.md +++ b/specs/2026-08-10-mcp-sdk-and-examples-plan.md @@ -7,6 +7,26 @@ authorizerdev/authorizer#757; this is what has to follow it, and in what order. --- +## 0. Correction after testing with a real client + +An earlier version of this plan (and of the docs) claimed Claude Code could +connect over OAuth once RFC 8252 loopback matching landed. **That is wrong.** +Tested against Claude Code 2.1.226: + +``` +authorizer-local: http://localhost:8099/mcp (HTTP) + - ✘ Failed to connect — Incompatible auth server: does not support dynamic client registration +``` + +Claude Code does not fall back to a manually-supplied client id; it refuses the +server. The loopback fix is necessary but nowhere near sufficient. The only +verified path today is a static bearer token bound to `/mcp`, which does +work — `claude mcp list` reports **✔ Connected**, and `tools/list` and +`tools/call` both function over the wire. + +This raises the priority of CIMD from "nice for zero-touch onboarding" to "the +thing that makes the OAuth machinery reachable by the flagship client at all". + ## 1. What actually changed for a client Three server changes have client-visible consequences. Everything below follows From 9935b368d1cbc971969c3c661eddccde0a9e2e2b Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 11 Aug 2026 10:38:57 +0530 Subject: [PATCH 3/3] docs(mcp): explain why there is no dynamic client registration A reader (or reviewer) hitting the missing /register endpoint should find the reasoning rather than assume an oversight. Records that the MCP spec 2025-11-25 demoted DCR to backwards-compat and made CIMD the SHOULD, what Auth0, Keycloak, Google and Anthropic actually do, and why CIMD is the planned path here. --- docs/core/mcp.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/core/mcp.md b/docs/core/mcp.md index 6b307e4..19be976 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -131,6 +131,37 @@ nothing useful and permission checks resolve to `service_account:`. F per-user identity you need the OAuth flow, which is why DCR/CIMD support matters and is tracked for a future release. +### Why there is no `/register` endpoint + +Authorizer deliberately does **not** implement RFC 7591 dynamic client +registration, and this is unlikely to change. + +The [MCP authorization spec (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +demoted it. Authorization servers **SHOULD** support Client ID Metadata +Documents and **MAY** support DCR, which the spec keeps only *"for backwards +compatibility with earlier versions of the MCP authorization spec"*. The client +priority order it defines is: pre-registered → CIMD → DCR → prompt the user. + +The industry moved the same way: + +| Product | Approach | +| --- | --- | +| [Auth0](https://auth0.com/ai/docs/mcp/guides/registering-your-mcp-client-application/dynamic-client-registration) | DCR is Enterprise-only, disabled by default, and needs tenant ACLs or a reverse proxy in front. Auth0 recommends CIMD instead for production | +| Keycloak | Has had OIDC DCR for years; ships experimental CIMD | +| Google Drive's MCP server | Rejects DCR outright (HTTP 400) | +| [Anthropic](https://claude.com/docs/connectors/building/authentication) | Steers directory traffic to CIMD or Anthropic-held credentials, because DCR registers a fresh client on every connection | + +Auth0's stated objections — resource depletion from mass registration, security +probing, unvetted misconfigured clients, audit gaps — apply with more force to a +self-hosted product, where every operator would inherit an open, unauthenticated +write endpoint and unbounded client-row growth. + +**CIMD is the planned path instead.** It makes the `client_id` an HTTPS URL that +the authorization server fetches and validates — no write endpoint, no row +growth, no schema change. It also requires a consent screen, because CIMD makes +client identity self-asserted: the spec requires the authorization server to +display the redirect URI hostname and to warn on `localhost`-only clients. + ## Exposed tools | Tool | Auth required | Description |