From 0b6433a9a548371b0957a5b157ec20ec0670755e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 14:32:19 -0700 Subject: [PATCH] Let an MCP probe name the ways its authorization server registers clients The probe required a registration_endpoint from every authorization server. HEY's lights up with pre-registered clients only and adds client ID metadata documents and dynamic registration later, and MCP now calls dynamic registration deprecated, so a server may offer only metadata documents. client_registration lists what must be advertised: dynamic (a registration_endpoint) and metadata_document (client_id_metadata_document_supported: true). Unset keeps [dynamic]; [] requires neither. --- CHANGELOG.md | 6 ++ README.md | 11 +++- app/models/upright/probes/mcp_probe.rb | 33 +++++++++- .../upright/install/templates/mcp_probes.yml | 3 + test/models/upright/probes/mcp_probe_test.rb | 62 +++++++++++++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34916e5..1c16073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ and metrics-missing alerts for the `mcp` type. Existing apps add `probes/mcp_probes.yml`, a `mcp_probes` entry in `config/recurring.yml` and the `mcp` alert rules from the install templates to use it. +- An MCP probe's optional `client_registration` lists the ways of registering a + client its authorization server must advertise: `dynamic` (a + `registration_endpoint`, RFC 7591) and `metadata_document` + (`client_id_metadata_document_supported: true`). Unset means `[dynamic]`, as + before; `[]` requires neither, for a server whose clients are all registered + ahead of time. - `config.public_status_json_enabled` serves the public status page as read-only v2 status JSON on the public status host, for status widgets, aggregators and chat integrations: `/api/v2/summary.json`, `status.json`, diff --git a/README.md b/README.md index 220a3d4..44782a3 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,16 @@ The probe checks the public side of a remote [MCP](https://modelcontextprotocol. 1. An unauthenticated `initialize` request to `url` returns 401 with a `resource_metadata` URL in the `WWW-Authenticate` header. The URL must be https on the same host as `url`. 2. The resource metadata names `url` as the `resource` and lists `authorization_server`. -3. The authorization server metadata has `authorization_endpoint`, `token_endpoint` and `registration_endpoint`, and supports the S256 code challenge method. +3. The authorization server metadata has `authorization_endpoint` and `token_endpoint`, supports the S256 code challenge method, and advertises each way of registering a client that `client_registration` names. + +The optional `client_registration` field lists those ways: `dynamic` requires a `registration_endpoint` (RFC 7591 dynamic client registration), and `metadata_document` requires `client_id_metadata_document_supported: true` (client ID metadata documents). It defaults to `[dynamic]`. Use `[metadata_document]` for a server that has dropped dynamic registration, both for a server that offers both, and `[]` for a server whose clients are all registered ahead of time: + +```yaml +- name: Example MCP Server + url: https://mcp.example.com/mcp + authorization_server: https://auth.example.com + client_registration: [ metadata_document ] +``` An HTTP probe cannot check this, because MCP endpoints accept only POST and the resource metadata is often cached by a CDN. The probe does not authenticate, so it does not call `tools/list`. The failing step is written to an `mcp.log` artifact on the result. diff --git a/app/models/upright/probes/mcp_probe.rb b/app/models/upright/probes/mcp_probe.rb index d3e43da..1cbed56 100644 --- a/app/models/upright/probes/mcp_probe.rb +++ b/app/models/upright/probes/mcp_probe.rb @@ -6,7 +6,9 @@ # 2. The resource metadata names the configured authorization server and the # endpoint itself as the protected resource. # 3. The authorization server metadata advertises the endpoints a client needs -# to register and sign in (authorization, token, dynamic client registration). +# to sign in (authorization, token, S256), and each way of registering a +# client that `client_registration` names: dynamic client registration +# (RFC 7591) by default, client ID metadata documents, both, or neither. class Upright::Probes::MCPProbe < FrozenRecord::Base include Upright::Probeable include Upright::ProbeYamlSource @@ -26,11 +28,13 @@ class CheckFailed < StandardError; end } }.to_json - REQUIRED_AUTHORIZATION_SERVER_KEYS = %w[ authorization_endpoint token_endpoint registration_endpoint ] + REQUIRED_AUTHORIZATION_SERVER_KEYS = %w[ authorization_endpoint token_endpoint ] + CLIENT_REGISTRATIONS = %w[ dynamic metadata_document ] def check @log = StringIO.new + verify_client_registration_option resource_metadata_url = discover_resource_metadata_url verify_resource_metadata(resource_metadata_url) verify_authorization_server_metadata @@ -87,6 +91,31 @@ def verify_authorization_server_metadata REQUIRED_AUTHORIZATION_SERVER_KEYS.each do |key| expect metadata[key].present?, "authorization server metadata has no #{key}" end + + verify_client_registration(metadata) + end + + def verify_client_registration(metadata) + if client_registrations.include?("dynamic") + expect metadata["registration_endpoint"].present?, "authorization server metadata has no registration_endpoint" + end + + if client_registrations.include?("metadata_document") + expect metadata["client_id_metadata_document_supported"] == true, + "authorization server metadata does not set client_id_metadata_document_supported to true" + end + end + + def verify_client_registration_option + unknown = client_registrations - CLIENT_REGISTRATIONS + expect unknown.empty?, "client_registration #{unknown.inspect} is not one of #{CLIENT_REGISTRATIONS.inspect}" + end + + # The ways of registering a client the authorization server must advertise. + # Unset means dynamic client registration; an empty list requires none, for + # a server whose clients are all registered ahead of time. + def client_registrations + self[:client_registration].nil? ? %w[ dynamic ] : Array(self[:client_registration]).map(&:to_s) end # RFC 8414: the well-known segment goes between the host and the issuer's path. diff --git a/lib/generators/upright/install/templates/mcp_probes.yml b/lib/generators/upright/install/templates/mcp_probes.yml index f72b0a1..74fcdf3 100644 --- a/lib/generators/upright/install/templates/mcp_probes.yml +++ b/lib/generators/upright/install/templates/mcp_probes.yml @@ -10,6 +10,9 @@ # # Optional fields: # - alert_severity: Alert severity when probe fails: medium, high (default), critical +# - client_registration: Ways of registering a client the authorization server +# must advertise: dynamic (a registration_endpoint) and metadata_document +# (client ID metadata documents). Default [dynamic]; [] requires neither. - name: Example MCP Server url: https://mcp.example.com/mcp diff --git a/test/models/upright/probes/mcp_probe_test.rb b/test/models/upright/probes/mcp_probe_test.rb index 2698651..f1ae469 100644 --- a/test/models/upright/probes/mcp_probe_test.rb +++ b/test/models/upright/probes/mcp_probe_test.rb @@ -80,6 +80,64 @@ class Upright::Probes::MCPProbeTest < ActiveSupport::TestCase assert_log "no registration_endpoint" end + test "fails when the authorization server does not support client ID metadata documents and the probe requires them" do + @probe = probe_with(client_registration: %w[ metadata_document ]) + stub_mcp + stub_json(METADATA_URL, resource: MCP_URL, authorization_servers: [ AUTH_SERVER ]) + stub_json("#{AUTH_SERVER}/.well-known/oauth-authorization-server", authorization_server_metadata) + + assert_not @probe.check + assert_log "does not set client_id_metadata_document_supported" + end + + test "passes without a registration endpoint when the probe requires only client ID metadata documents" do + @probe = probe_with(client_registration: %w[ metadata_document ]) + stub_mcp + stub_json(METADATA_URL, resource: MCP_URL, authorization_servers: [ AUTH_SERVER ]) + stub_json("#{AUTH_SERVER}/.well-known/oauth-authorization-server", + authorization_server_metadata.except(:registration_endpoint).merge(client_id_metadata_document_supported: true)) + + assert @probe.check + end + + test "requires every way of registering a client the probe names" do + @probe = probe_with(client_registration: %w[ dynamic metadata_document ]) + stub_mcp + stub_json(METADATA_URL, resource: MCP_URL, authorization_servers: [ AUTH_SERVER ]) + stub_json("#{AUTH_SERVER}/.well-known/oauth-authorization-server", + authorization_server_metadata.except(:registration_endpoint).merge(client_id_metadata_document_supported: true)) + + assert_not @probe.check + assert_log "no registration_endpoint" + end + + test "passes with no way of registering a client when the probe requires none" do + @probe = probe_with(client_registration: []) + stub_mcp + stub_json(METADATA_URL, resource: MCP_URL, authorization_servers: [ AUTH_SERVER ]) + stub_json("#{AUTH_SERVER}/.well-known/oauth-authorization-server", authorization_server_metadata.except(:registration_endpoint)) + + assert @probe.check + end + + test "still requires the sign-in endpoints when the probe requires no way of registering a client" do + @probe = probe_with(client_registration: []) + stub_mcp + stub_json(METADATA_URL, resource: MCP_URL, authorization_servers: [ AUTH_SERVER ]) + stub_json("#{AUTH_SERVER}/.well-known/oauth-authorization-server", authorization_server_metadata.except(:registration_endpoint, :token_endpoint)) + + assert_not @probe.check + assert_log "no token_endpoint" + end + + test "fails before any request when client_registration names an unknown way" do + @probe = probe_with(client_registration: %w[ dcr ]) + + assert_not @probe.check + assert_log %(client_registration ["dcr"] is not one of) + assert_not_requested :post, MCP_URL + end + test "fails when the endpoint times out" do stub_request(:post, MCP_URL).to_timeout @@ -95,6 +153,10 @@ class Upright::Probes::MCPProbeTest < ActiveSupport::TestCase end private + def probe_with(**options) + Upright::Probes::MCPProbe.new(name: "test", url: MCP_URL, authorization_server: AUTH_SERVER, **options) + end + def stub_all stub_mcp stub_json(METADATA_URL, resource: MCP_URL, authorization_servers: [ AUTH_SERVER ])