Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 31 additions & 2 deletions app/models/upright/probes/mcp_probe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions lib/generators/upright/install/templates/mcp_probes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions test/models/upright/probes/mcp_probe_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 ])
Expand Down
Loading