From f68562086d6e833aa15ecea944c3ca1d94e77f7d Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Sat, 18 Jul 2026 13:00:07 +0200 Subject: [PATCH 1/3] Serve OAuth metadata path-appended too, so appended-convention clients discover the AS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A path-ful MCP issuer (https://host/mcp) has two readings of where its OAuth metadata lives: RFC 8414 §3.1 INSERTS the well-known segment before the path (/.well-known/oauth-authorization-server/mcp, already served), while some clients APPEND it (host/mcp/.well-known/oauth-authorization-server). The latter 404'd with no fallback, so a client using that convention never got a registration_endpoint and reported a registration failure. Draw the appended forms (AS + protected-resource + openid-configuration OIDC alias) under the engine mount, gated on oauth_bridge?. They stay path-scoped, claim nothing origin-global, and reuse the same actions, so each document is byte- identical to its inserted twin and carries Cache-Control: no-store. Bump to 0.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 33 ++++++++++++ README.md | 20 +++++-- config/routes.rb | 28 +++++++--- lib/mcp_toolkit/version.rb | 2 +- spec/mcp_toolkit/engine_spec.rb | 9 +++- .../oauth/bridge_end_to_end_spec.rb | 52 ++++++++++++++++++- 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f8e9b..890d90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +## [0.6.1] - 2026-07-18 + +Serves the OAuth metadata documents at the path-APPENDED discovery locations too, +so a client that appends the well-known segment after the resource path +(`/.well-known/oauth-authorization-server`) discovers the authorization +server just as one that inserts it before (`/.well-known/oauth-authorization-server/mcp`) +does. Additive and bridge-gated: a host with the bridge off is unaffected, and one +with it on gains the aliases with no configuration change. + +### Fixed + +- **Authorization-server discovery for clients that path-APPEND the well-known + segment.** For an MCP endpoint at `https://host/mcp`, the issuer is path-ful, + and RFC 8414 §3.1 places its metadata at the path-INSERTED + `/.well-known/oauth-authorization-server/mcp` — which the bridge already served. + But some MCP clients (observed with a hosted client in the wild) instead request + the path-APPENDED `https://host/mcp/.well-known/oauth-authorization-server`, got + a 404 with no fallback, never obtained a `registration_endpoint`, and reported a + registration failure. The bridge now answers the metadata at BOTH forms. + +### Added + +- **Path-appended metadata routes under the engine mount** (drawn only when + `oauth_bridge?`): `GET /.well-known/oauth-authorization-server`, + `GET /.well-known/oauth-protected-resource`, and the OIDC discovery alias + `GET /.well-known/openid-configuration` (which returns the + authorization-server document). All stay UNDER the mount, so they claim nothing + origin-global and cannot collide with an OAuth provider the host already runs; + the RFC 8414 path-inserted documents the host draws at the origin root are + unchanged. Every identifier is still derived from the live request origin, so + the document served at an appended location is byte-identical to the inserted + one, and both carry `Cache-Control: no-store`. + ## [0.6.0] - 2026-07-16 An OAuth 2.1 authorization bridge for the authority role, so hosted MCP clients diff --git a/README.md b/README.md index 3ffbc28..c22cff9 100644 --- a/README.md +++ b/README.md @@ -543,16 +543,26 @@ origin-global.** The flow endpoints live under the engine's mount `/oauth/*` — as an app with Doorkeeper for its own API does — you keep every one of those routes. -The metadata documents are **path-scoped** to the mount -(`/.well-known/oauth-protected-resource/mcp`), never the bare -`/.well-known/oauth-authorization-server`. That matters: the bare paths are -origin-global and mean *"the authorization server of this whole origin"*, which -belongs to a provider you already run, not to an MCP server sharing the host. +The metadata documents are **path-scoped** to the mount, never the bare +origin-global `/.well-known/oauth-authorization-server`. That matters: the bare +paths are origin-global and mean *"the authorization server of this whole origin"*, +which belongs to a provider you already run, not to an MCP server sharing the host. RFC 8414 §3.1 exists for exactly this — *"Using path components enables supporting multiple issuers per host"* — and the MCP authorization spec (2025-11-25) requires a client given a path-ful issuer to try the path-**inserted** URLs, with no root fallback. So the issuer is your MCP endpoint URL, and both documents hang off it. +A path-ful issuer, though, has two readings of *where* under the origin its +metadata lives, and MCP clients disagree: some **insert** the well-known segment +before the resource path (`/.well-known/oauth-authorization-server/mcp` — the RFC +form the host draws), others **append** it after +(`/mcp/.well-known/oauth-authorization-server`). The bridge serves both: the +inserted forms at the origin root (`draw_oauth_metadata_routes`), and the appended +forms — plus the `openid-configuration` OIDC alias — under the engine mount, +automatically whenever the bridge is on. Both stay path-scoped and claim nothing +origin-global; a client discovers the authorization server whichever convention it +follows. + If your MCP endpoint IS its origin root (a dedicated MCP domain), there is no path to insert and you get the bare paths — correct there, since your server really is that origin's only authorization server. Set `oauth_resource_path = "/"`. diff --git a/config/routes.rb b/config/routes.rb index c43c8a7..4267785 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,17 +38,31 @@ # route above: the routes file is evaluated through the routes_reloader, after # the host's initializers/to_prepare, so the config is already set. # - # The two metadata documents are NOT here: a client looks for them at the origin - # root, which an engine mounted under a path cannot draw. The host draws them - # with `McpToolkit.draw_oauth_metadata_routes(self)`. - # `format: false` on each, as on the metadata routes the host draws: without it - # Rails' optional `(.:format)` segment matches, so `/mcp/oauth/authorize.json` - # reaches the action, finds no JSON template, and 500s — an unauthenticated - # error on a public endpoint, for a format the bridge never speaks. + # `format: false` on each: without it Rails' optional `(.:format)` segment + # matches, so `/mcp/oauth/authorize.json` reaches the action, finds no JSON + # template, and 500s — an unauthenticated error on a public endpoint, for a + # format the bridge never speaks. if McpToolkit.config.oauth_bridge? get "oauth/authorize", to: "oauth#authorize", format: false post "oauth/authorize", to: "oauth#approve", format: false post "oauth/token", to: "oauth#token", format: false post "oauth/register", to: "oauth#register", format: false + + # The metadata documents, ALSO served path-APPENDED to the mount + # (`/mcp/.well-known/oauth-authorization-server`), in addition to the RFC 8414 + # §3.1 / RFC 9728 §3.1 path-INSERTED locations the host draws at the origin + # root via `McpToolkit.draw_oauth_metadata_routes`. A path-ful issuer has two + # readings of where its metadata lives, and MCP clients disagree: some INSERT + # the well-known segment before the resource path (the RFC form), others APPEND + # it after. Observed in the wild — a hosted client requesting + # `/.well-known/oauth-authorization-server` and getting a 404 it could not + # recover from, so it never reached registration. Serving both meets either + # convention. Unlike the origin-root bare paths, these stay UNDER the mount, so + # they claim nothing origin-global and cannot collide with an OAuth provider + # the host already runs. `openid-configuration` is the OIDC discovery alias a + # client may probe instead; it answers the same authorization-server document. + get ".well-known/oauth-authorization-server", to: "oauth#authorization_server", format: false + get ".well-known/oauth-protected-resource", to: "oauth#protected_resource", format: false + get ".well-known/openid-configuration", to: "oauth#authorization_server", format: false end end diff --git a/lib/mcp_toolkit/version.rb b/lib/mcp_toolkit/version.rb index edfcda2..4d8be86 100644 --- a/lib/mcp_toolkit/version.rb +++ b/lib/mcp_toolkit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module McpToolkit - VERSION = "0.6.0" + VERSION = "0.6.1" end diff --git a/spec/mcp_toolkit/engine_spec.rb b/spec/mcp_toolkit/engine_spec.rb index a1aa099..0ab22c7 100644 --- a/spec/mcp_toolkit/engine_spec.rb +++ b/spec/mcp_toolkit/engine_spec.rb @@ -179,7 +179,14 @@ def drawn_formats [:get, "oauth/authorize", "oauth#authorize"], [:post, "oauth/authorize", "oauth#approve"], [:post, "oauth/token", "oauth#token"], - [:post, "oauth/register", "oauth#register"] + [:post, "oauth/register", "oauth#register"], + # The metadata documents served path-APPENDED under the mount, for clients + # that append the well-known segment after the resource path rather than + # inserting it. Gated with the rest of the bridge; format segment disabled + # like the rest (the checks below fold these in via `oauth_routes`). + [:get, ".well-known/oauth-authorization-server", "oauth#authorization_server"], + [:get, ".well-known/oauth-protected-resource", "oauth#protected_resource"], + [:get, ".well-known/openid-configuration", "oauth#authorization_server"] ] it "does NOT draw the OAuth bridge for an authority that has not opted in" do diff --git a/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb b/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb index 82307b9..78c03a6 100644 --- a/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb +++ b/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb @@ -161,6 +161,23 @@ def token_info = render(plain: "HOST_TOKEN_INFO") session.get("/.well-known/oauth-authorization-server") result["bare_as_status"] = session.last_response.status + # 3b. The SAME documents, served path-APPENDED under the mount, for a client + # that appends the well-known segment after the resource path instead of + # inserting it. These stay under the mount, so they are additive to the + # inserted forms and claim nothing origin-global. + session.get("/mcp/.well-known/oauth-authorization-server") + result["appended_as_status"] = session.last_response.status + result["appended_as"] = JSON.parse(session.last_response.body) + result["appended_as_cache_control"] = session.last_response.headers["Cache-Control"] + + session.get("/mcp/.well-known/oauth-protected-resource") + result["appended_prm_status"] = session.last_response.status + result["appended_prm"] = JSON.parse(session.last_response.body) + + session.get("/mcp/.well-known/openid-configuration") + result["appended_oidc_status"] = session.last_response.status + result["appended_oidc"] = JSON.parse(session.last_response.body) + # 4. Client registration. session.post("/mcp/oauth/register", JSON.generate({ redirect_uris: [REDIRECT_URI] }), "CONTENT_TYPE" => "application/json") @@ -323,7 +340,10 @@ def token_info = render(plain: "HOST_TOKEN_INFO") it "keeps every one of its own endpoints under the engine mount" do expect(@result.fetch("engine_paths")).to contain_exactly( "/", "/", "/", "/health", "/tokens/introspect", - "/oauth/authorize", "/oauth/authorize", "/oauth/token", "/oauth/register" + "/oauth/authorize", "/oauth/authorize", "/oauth/token", "/oauth/register", + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource", + "/.well-known/openid-configuration" ) end end @@ -473,6 +493,36 @@ def token_info = render(plain: "HOST_TOKEN_INFO") expect(@result.fetch("bare_prm_status")).to eq(404) expect(@result.fetch("bare_as_status")).to eq(404) end + + # A client that appends the well-known segment after the resource path gets the + # same documents as one that inserts it — the byte-identical issuer + endpoints, + # and the same no-store — so discovery succeeds under either convention. + it "also answers authorization-server metadata at the path-APPENDED location" do + expect(@result.fetch("appended_as_status")).to eq(200) + expect(@result.fetch("appended_as")).to include( + "issuer" => "http://example.org/mcp", + "authorization_endpoint" => "http://example.org/mcp/oauth/authorize", + "token_endpoint" => "http://example.org/mcp/oauth/token", + "registration_endpoint" => "http://example.org/mcp/oauth/register" + ) + expect(@result.fetch("appended_as_cache_control")).to eq("no-store") + end + + it "also answers protected-resource metadata at the path-APPENDED location" do + expect(@result.fetch("appended_prm_status")).to eq(200) + expect(@result.fetch("appended_prm")).to include( + "resource" => "http://example.org/mcp", + "authorization_servers" => ["http://example.org/mcp"] + ) + end + + it "answers the OIDC discovery alias with the authorization-server document" do + expect(@result.fetch("appended_oidc_status")).to eq(200) + expect(@result.fetch("appended_oidc")).to include( + "issuer" => "http://example.org/mcp", + "registration_endpoint" => "http://example.org/mcp/oauth/register" + ) + end end describe "registration" do From f2bc141f231a2d4ee124ac28796309c5422ae0cb Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Mon, 20 Jul 2026 10:40:36 +0200 Subject: [PATCH 2/3] Echo the registered client metadata in the DCR response (RFC 7591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The register stub returned only a client_id plus a fixed auth method and grant/ response types. A strict client validates that the redirect_uris it registered come back and abandons a registration that drops them — a plausible cause of the "couldn't register" failure independent of the appended-metadata discovery gap (a hosted client was unblocked simply by supplying a manual client_id, which skips DCR entirely, confirming the registration ceremony is the failure locus). register now reflects the client's redirect_uris, token_endpoint_auth_method, grant_types, response_types and client_name, and adds client_id_issued_at / client_id_expires_at: 0. Echoing authorizes nothing: no client is stored, and authorize/token still gate every redirect_uri against the host allowlist, so a value reflected here is not thereby permitted. Still 0.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++---- lib/mcp_toolkit/oauth/controller_methods.rb | 33 +++++++++++++---- .../oauth/bridge_end_to_end_spec.rb | 14 ++++++++ .../oauth/controller_methods_spec.rb | 36 +++++++++++++++++++ 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 890d90b..a9598cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ -## [0.6.1] - 2026-07-18 +## [0.6.1] - 2026-07-20 -Serves the OAuth metadata documents at the path-APPENDED discovery locations too, -so a client that appends the well-known segment after the resource path -(`/.well-known/oauth-authorization-server`) discovers the authorization -server just as one that inserts it before (`/.well-known/oauth-authorization-server/mcp`) -does. Additive and bridge-gated: a host with the bridge off is unaffected, and one -with it on gains the aliases with no configuration change. +Two additive fixes for hosted MCP clients whose OAuth setup could not complete +against the 0.6.0 bridge: it now answers the metadata documents at the path-APPENDED +discovery locations as well as the RFC 8414 path-inserted ones, and its Dynamic +Client Registration response echoes the client's submitted metadata (per RFC 7591) +instead of returning a bare `client_id`. Both are bridge-gated and change nothing +for a host with the bridge off, or for a client that already worked. ### Fixed @@ -18,6 +18,18 @@ with it on gains the aliases with no configuration change. a 404 with no fallback, never obtained a `registration_endpoint`, and reported a registration failure. The bridge now answers the metadata at BOTH forms. +- **Dynamic Client Registration response now echoes the registered metadata.** + The stub returned only a `client_id` (plus a fixed auth method and grant/response + types). A strict client validates that the `redirect_uris` it registered come + back and abandons a registration that drops them — a plausible cause of the same + "couldn't register" failure, independent of the discovery gap above. `register` + now reflects the client's `redirect_uris`, `token_endpoint_auth_method`, + `grant_types`, `response_types` and `client_name`, and adds `client_id_issued_at` + / `client_id_expires_at: 0` (RFC 7591 §3.2.1). Echoing AUTHORIZES nothing: the + bridge still stores no client, and `authorize`/`token` check every `redirect_uri` + against the host allowlist independently, so a value reflected here is not thereby + permitted. + ### Added - **Path-appended metadata routes under the engine mount** (drawn only when diff --git a/lib/mcp_toolkit/oauth/controller_methods.rb b/lib/mcp_toolkit/oauth/controller_methods.rb index 22f44e0..bb5ccb9 100644 --- a/lib/mcp_toolkit/oauth/controller_methods.rb +++ b/lib/mcp_toolkit/oauth/controller_methods.rb @@ -93,15 +93,28 @@ def authorization_server } end - # Stateless: persisting a `client_id` nothing reads would only grow a table of - # strings the bridge never consults. + # Stateless: nothing here is persisted (no endpoint reads a `client_id`). The + # response nonetheless echoes the client's own submitted metadata, as RFC 7591 + # §3.2.1 describes a registration response — a strict client validates that the + # `redirect_uris` it registered come back and abandons a registration that drops + # them (the failure a hosted client hit against the pre-0.6.1 stub, which + # returned only a `client_id`). Echoing AUTHORIZES nothing: `authorize`/`token` + # still check every `redirect_uri` against the host's allowlist independently, so + # a value reflected here is not thereby permitted. The auth method and the + # grant/response types are reflected for the same compatibility reason; the token + # endpoint accepts only `authorization_code` whatever is echoed. def register - render json: { + body = { client_id: SecureRandom.uuid, - token_endpoint_auth_method: "none", - grant_types: ["authorization_code"], - response_types: ["code"] - }, status: :created + client_id_issued_at: Time.now.to_i, + client_id_expires_at: 0, + redirect_uris: mcp_oauth_param_list(:redirect_uris), + token_endpoint_auth_method: params[:token_endpoint_auth_method].presence&.to_s || "none", + grant_types: mcp_oauth_param_list(:grant_types).presence || ["authorization_code"], + response_types: mcp_oauth_param_list(:response_types).presence || ["code"] + } + body[:client_name] = params[:client_name].to_s if params[:client_name].present? + render json: body, status: :created end # `formats: [:html]` because there is only an HTML template and `Accept` picks @@ -151,6 +164,12 @@ def mcp_oauth_config McpToolkit.config end + # A registration parameter as a plain array of strings — RFC 7591 lists arrive + # as JSON arrays; empty when the client sent none. + def mcp_oauth_param_list(key) + Array(params[key]).map(&:to_s) + end + # ---- request validation --------------------------------------------------- # Halts both legs before their action runs. Both problems RENDER rather than diff --git a/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb b/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb index 78c03a6..a71f2ed 100644 --- a/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb +++ b/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb @@ -530,6 +530,20 @@ def token_info = render(plain: "HOST_TOKEN_INFO") expect(@result.fetch("register_status")).to eq(201) expect(@result.fetch("register")["client_id"]).to be_a(String).and be_present end + + # RFC 7591 §3.2.1: a strict client validates that the redirect_uris it + # registered come back, and abandons a registration that drops them. Echoing + # them registers nothing — the allowlist still gates authorize/token — but a + # client that requires the echo can now complete registration. + it "echoes the submitted redirect_uris and RFC 7591 client fields" do + register = @result.fetch("register") + + expect(register["redirect_uris"]).to eq(["https://client.example/callback"]) + expect(register["client_id_expires_at"]).to eq(0) + expect(register["client_id_issued_at"]).to be_a(Integer) + expect(register["grant_types"]).to eq(["authorization_code"]) + expect(register["response_types"]).to eq(["code"]) + end end describe "the authorization page" do diff --git a/spec/mcp_toolkit/oauth/controller_methods_spec.rb b/spec/mcp_toolkit/oauth/controller_methods_spec.rb index d78a3b4..41ebcf2 100644 --- a/spec/mcp_toolkit/oauth/controller_methods_spec.rb +++ b/spec/mcp_toolkit/oauth/controller_methods_spec.rb @@ -210,6 +210,42 @@ def issue_code(overrides = {}) expect(controller.rendered[:options][:json][:client_id]).not_to eq(first) end + + # RFC 7591 §3.2.1: a strict client validates that the redirect_uris it sent + # come back, and abandons a registration that drops them. Echoing them + # authorizes nothing — authorize/token still gate every redirect_uri against + # the allowlist (see the #authorize examples below). + it "echoes the submitted redirect_uris and client metadata" do + controller.params = { + redirect_uris: [redirect_uri], + grant_types: %w[authorization_code refresh_token], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + client_name: "Some Client" + } + controller.register + + expect(controller.rendered[:options][:json]).to include( + redirect_uris: [redirect_uri], + grant_types: %w[authorization_code refresh_token], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + client_name: "Some Client", + client_id_expires_at: 0 + ) + expect(controller.rendered[:options][:json][:client_id_issued_at]).to be_a(Integer) + end + + it "defaults the grant/response types and auth method when the client sends none" do + controller.register + + expect(controller.rendered[:options][:json]).to include( + redirect_uris: [], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none" + ) + end end describe "#authorize" do From f55d2119fc2d209d6a5aee745442a31b229a59c4 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Mon, 20 Jul 2026 12:04:23 +0200 Subject: [PATCH 3/3] State the registration in the DCR response, don't echo the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f2bc141 reflected the client's token_endpoint_auth_method, grant_types and response_types back verbatim. That contradicts our own discovery document — the client fetched registration_endpoint FROM authorization_server, which advertises token_endpoint_auth_methods_supported: ["none"] and grant_types_supported: ["authorization_code"] — and it promises flows the token endpoint rejects: a reflected refresh_token is a refresh answered unsupported_grant_type, and a reflected implicit response_type is a request authorize never honours. RFC 7591 §3.2.1 states the metadata as REGISTERED and lets a server replace what it does not support, so those three are now substituted rather than echoed. The supported sets are named once (SUPPORTED_GRANT_TYPES / SUPPORTED_RESPONSE_TYPES) and shared by both documents, so they cannot drift apart; a spec pins the two against each other. redirect_uris and client_name are still echoed — that is the compatibility payload the commit was for, and the only part with evidence behind it. Also drops client_id_expires_at, which RFC 7591 does not define (it defines client_secret_expires_at, and no client_secret is ever issued here). It was added under a comment citing §3.2.1, which is the class of error where a comment claims a compliance the code does not have. The E2E now registers asking for refresh_token, an implicit response type and client_secret_post, so substitution is exercised over a real request cycle rather than the defaults an empty body would take. 617 examples, 0 failures across three random orders; rubocop 65/0; brakeman 0 with CI's flags. Still 0.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 35 ++++++----- lib/mcp_toolkit/oauth/controller_methods.rb | 46 +++++++++----- .../oauth/bridge_end_to_end_spec.rb | 23 +++++-- .../oauth/controller_methods_spec.rb | 61 +++++++++++++++---- 4 files changed, 121 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9598cb..5dc43a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ Two additive fixes for hosted MCP clients whose OAuth setup could not complete against the 0.6.0 bridge: it now answers the metadata documents at the path-APPENDED discovery locations as well as the RFC 8414 path-inserted ones, and its Dynamic -Client Registration response echoes the client's submitted metadata (per RFC 7591) -instead of returning a bare `client_id`. Both are bridge-gated and change nothing -for a host with the bridge off, or for a client that already worked. +Client Registration response now states the registration (per RFC 7591) instead of +returning a bare `client_id`. Both are bridge-gated and change nothing for a host +with the bridge off, or for a client that already worked. ### Fixed @@ -18,17 +18,24 @@ for a host with the bridge off, or for a client that already worked. a 404 with no fallback, never obtained a `registration_endpoint`, and reported a registration failure. The bridge now answers the metadata at BOTH forms. -- **Dynamic Client Registration response now echoes the registered metadata.** - The stub returned only a `client_id` (plus a fixed auth method and grant/response - types). A strict client validates that the `redirect_uris` it registered come - back and abandons a registration that drops them — a plausible cause of the same - "couldn't register" failure, independent of the discovery gap above. `register` - now reflects the client's `redirect_uris`, `token_endpoint_auth_method`, - `grant_types`, `response_types` and `client_name`, and adds `client_id_issued_at` - / `client_id_expires_at: 0` (RFC 7591 §3.2.1). Echoing AUTHORIZES nothing: the - bridge still stores no client, and `authorize`/`token` check every `redirect_uri` - against the host allowlist independently, so a value reflected here is not thereby - permitted. +- **Dynamic Client Registration response now states the registration.** The stub + returned only a `client_id`. A strict client validates that the `redirect_uris` + it registered come back and abandons a registration that drops them — a plausible + cause of the same "couldn't register" failure, independent of the discovery gap + above. `register` now returns the client's `redirect_uris` and `client_name` + alongside `client_id_issued_at` (RFC 7591 §3.2.1). Echoing a `redirect_uri` + AUTHORIZES nothing: the bridge still stores no client, and `authorize`/`token` + check every one against the host allowlist independently, so a value reflected + here is not thereby permitted. + + The `token_endpoint_auth_method` and the grant/response types are **substituted, + not echoed** — RFC 7591 §3.2.1 states the metadata as REGISTERED and lets a server + replace what it does not support. Reflecting the client's request would contradict + the discovery document that named this endpoint (`token_endpoint_auth_methods_supported: + ["none"]`, `grant_types_supported: ["authorization_code"]`) and promise a flow the + token endpoint rejects — a reflected `refresh_token` is a refresh answered + `unsupported_grant_type`. The supported sets are now named once and shared by both + documents, so they cannot drift apart. ### Added diff --git a/lib/mcp_toolkit/oauth/controller_methods.rb b/lib/mcp_toolkit/oauth/controller_methods.rb index bb5ccb9..f057e6f 100644 --- a/lib/mcp_toolkit/oauth/controller_methods.rb +++ b/lib/mcp_toolkit/oauth/controller_methods.rb @@ -37,6 +37,12 @@ module McpToolkit::Oauth::ControllerMethods # base64url of a SHA-256, which is always exactly 43 of the same alphabet. PKCE_VALUE = /\A[A-Za-z0-9\-._~]{43,128}\z/ + # What the token endpoint actually honours. Named once because the discovery + # document and the registration response MUST agree — a client reads the first + # to find the second, so a disagreement is this server contradicting itself. + SUPPORTED_GRANT_TYPES = %w[authorization_code].freeze + SUPPORTED_RESPONSE_TYPES = %w[code].freeze + included do # Safe to disable: the token endpoint is called server-to-server without a CSRF # token, and `approve` never acts on ambient authority — it reads no session and @@ -86,32 +92,35 @@ def authorization_server authorization_endpoint: mcp_oauth_endpoint_url("authorize"), token_endpoint: mcp_oauth_endpoint_url("token"), registration_endpoint: mcp_oauth_endpoint_url("register"), - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], + response_types_supported: SUPPORTED_RESPONSE_TYPES, + grant_types_supported: SUPPORTED_GRANT_TYPES, code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"] } end # Stateless: nothing here is persisted (no endpoint reads a `client_id`). The - # response nonetheless echoes the client's own submitted metadata, as RFC 7591 - # §3.2.1 describes a registration response — a strict client validates that the - # `redirect_uris` it registered come back and abandons a registration that drops - # them (the failure a hosted client hit against the pre-0.6.1 stub, which - # returned only a `client_id`). Echoing AUTHORIZES nothing: `authorize`/`token` - # still check every `redirect_uri` against the host's allowlist independently, so - # a value reflected here is not thereby permitted. The auth method and the - # grant/response types are reflected for the same compatibility reason; the token - # endpoint accepts only `authorization_code` whatever is echoed. + # response still names the `redirect_uris` the client sent, because a strict + # client validates that they come back and abandons a registration that drops + # them — the failure a hosted client hit against the pre-0.6.1 stub, which + # returned only a `client_id`. That echo AUTHORIZES nothing: `authorize` and + # `token` check every `redirect_uri` against the host's allowlist independently. + # + # The auth method and the grant/response types are SUBSTITUTED rather than + # echoed — RFC 7591 §3.2.1 states the metadata as REGISTERED, and lets a server + # replace what it does not support. Reflecting the client's request instead + # would contradict `authorization_server`, which is where that client got this + # endpoint, and would promise a flow `token` rejects: a `refresh_token` echoed + # back is a refresh answered `unsupported_grant_type`. No `client_secret` is + # issued, so `none` is the only auth method a client here could perform. def register body = { client_id: SecureRandom.uuid, client_id_issued_at: Time.now.to_i, - client_id_expires_at: 0, redirect_uris: mcp_oauth_param_list(:redirect_uris), - token_endpoint_auth_method: params[:token_endpoint_auth_method].presence&.to_s || "none", - grant_types: mcp_oauth_param_list(:grant_types).presence || ["authorization_code"], - response_types: mcp_oauth_param_list(:response_types).presence || ["code"] + token_endpoint_auth_method: "none", + grant_types: mcp_oauth_registered_subset(:grant_types, SUPPORTED_GRANT_TYPES), + response_types: mcp_oauth_registered_subset(:response_types, SUPPORTED_RESPONSE_TYPES) } body[:client_name] = params[:client_name].to_s if params[:client_name].present? render json: body, status: :created @@ -170,6 +179,13 @@ def mcp_oauth_param_list(key) Array(params[key]).map(&:to_s) end + # What the client asked for, narrowed to what this server honours. A client that + # asked for NOTHING supported gets the default rather than an empty array, which + # would state a registration able to do nothing at all. + def mcp_oauth_registered_subset(key, supported) + (mcp_oauth_param_list(key) & supported).presence || supported + end + # ---- request validation --------------------------------------------------- # Halts both legs before their action runs. Both problems RENDER rather than diff --git a/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb b/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb index a71f2ed..8865350 100644 --- a/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb +++ b/spec/mcp_toolkit/oauth/bridge_end_to_end_spec.rb @@ -178,8 +178,14 @@ def token_info = render(plain: "HOST_TOKEN_INFO") result["appended_oidc_status"] = session.last_response.status result["appended_oidc"] = JSON.parse(session.last_response.body) - # 4. Client registration. - session.post("/mcp/oauth/register", JSON.generate({ redirect_uris: [REDIRECT_URI] }), + # 4. Client registration. Asks for more than the bridge honours, so the + # response below exercises substitution over a real request cycle rather + # than the defaults an empty body would take. + session.post("/mcp/oauth/register", + JSON.generate({ redirect_uris: [REDIRECT_URI], + grant_types: %w[authorization_code refresh_token], + response_types: %w[code token], + token_endpoint_auth_method: "client_secret_post" }), "CONTENT_TYPE" => "application/json") result["register_status"] = session.last_response.status result["register"] = JSON.parse(session.last_response.body) @@ -535,14 +541,23 @@ def token_info = render(plain: "HOST_TOKEN_INFO") # registered come back, and abandons a registration that drops them. Echoing # them registers nothing — the allowlist still gates authorize/token — but a # client that requires the echo can now complete registration. - it "echoes the submitted redirect_uris and RFC 7591 client fields" do + it "echoes the submitted redirect_uris" do register = @result.fetch("register") expect(register["redirect_uris"]).to eq(["https://client.example/callback"]) - expect(register["client_id_expires_at"]).to eq(0) expect(register["client_id_issued_at"]).to be_a(Integer) + end + + # The request asked for refresh_token, an implicit response type and a secret + # this server never issues. Stating them back would promise flows the token + # endpoint rejects, and contradict the very document that named this endpoint. + it "states only what it honours, whatever the client asked to register" do + register = @result.fetch("register") + expect(register["grant_types"]).to eq(["authorization_code"]) expect(register["response_types"]).to eq(["code"]) + expect(register["token_endpoint_auth_method"]).to eq("none") + expect(register).not_to have_key("client_id_expires_at") end end diff --git a/spec/mcp_toolkit/oauth/controller_methods_spec.rb b/spec/mcp_toolkit/oauth/controller_methods_spec.rb index 41ebcf2..2e1919e 100644 --- a/spec/mcp_toolkit/oauth/controller_methods_spec.rb +++ b/spec/mcp_toolkit/oauth/controller_methods_spec.rb @@ -215,25 +215,44 @@ def issue_code(overrides = {}) # come back, and abandons a registration that drops them. Echoing them # authorizes nothing — authorize/token still gate every redirect_uri against # the allowlist (see the #authorize examples below). - it "echoes the submitted redirect_uris and client metadata" do - controller.params = { + it "echoes the submitted redirect_uris and client_name" do + controller.params = { redirect_uris: [redirect_uri], client_name: "Some Client" } + controller.register + + expect(controller.rendered[:options][:json]).to include( redirect_uris: [redirect_uri], - grant_types: %w[authorization_code refresh_token], - response_types: ["code"], - token_endpoint_auth_method: "client_secret_post", client_name: "Some Client" + ) + expect(controller.rendered[:options][:json][:client_id_issued_at]).to be_a(Integer) + end + + # RFC 7591 §3.2.1 states the metadata as REGISTERED, not as requested. Echoing + # a grant this server rejects would promise the client a flow #token answers + # `unsupported_grant_type`, and echoing an auth method would confirm a + # credential no client here holds — no client_secret is ever issued. + it "substitutes grant/response types it does not support rather than echoing them" do + controller.params = { + grant_types: %w[authorization_code refresh_token], + response_types: %w[code token], + token_endpoint_auth_method: "client_secret_post" } controller.register expect(controller.rendered[:options][:json]).to include( - redirect_uris: [redirect_uri], - grant_types: %w[authorization_code refresh_token], + grant_types: ["authorization_code"], response_types: ["code"], - token_endpoint_auth_method: "client_secret_post", - client_name: "Some Client", - client_id_expires_at: 0 + token_endpoint_auth_method: "none" + ) + end + + it "falls back to the supported defaults when the client asks for nothing it honours" do + controller.params = { grant_types: ["client_credentials"], response_types: ["token"] } + controller.register + + expect(controller.rendered[:options][:json]).to include( + grant_types: ["authorization_code"], + response_types: ["code"] ) - expect(controller.rendered[:options][:json][:client_id_issued_at]).to be_a(Integer) end it "defaults the grant/response types and auth method when the client sends none" do @@ -246,6 +265,26 @@ def issue_code(overrides = {}) token_endpoint_auth_method: "none" ) end + + # The client fetched this endpoint FROM the discovery document, so the two + # disagreeing is the server contradicting itself — the defect the substitution + # above exists to prevent, pinned against both documents at once. + it "states nothing the discovery document does not advertise as supported" do + controller.params = { + grant_types: %w[authorization_code refresh_token], + response_types: %w[code token], + token_endpoint_auth_method: "private_key_jwt" + } + controller.register + registered = controller.rendered[:options][:json] + controller.authorization_server + advertised = controller.rendered[:options][:json] + + expect(registered[:grant_types] - advertised[:grant_types_supported]).to be_empty + expect(registered[:response_types] - advertised[:response_types_supported]).to be_empty + expect(advertised[:token_endpoint_auth_methods_supported]) + .to include(registered[:token_endpoint_auth_method]) + end end describe "#authorize" do