Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,20 @@ validation](https://modelcontextprotocol.io/specification/2025-06-18/basic/trans
principle recommended by the specification to protect against DNS rebinding
attacks.

**Authorization.** The specification's
[authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
model has this endpoint act as an OAuth 2.1 resource server that tells a client
where its tokens come from. Where a [`jwt`](configuration.md#jwt) policy names
this endpoint as its `audience`, the instance serves that discovery both ways
the specification accepts: [RFC
9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource
metadata at its standard well-known location, and a `resource_metadata`
parameter naming it in the `WWW-Authenticate` header of every denial here. A
policy that protects more than this endpoint describes a wider audience and is
not advertised, since a token bound to this endpoint alone is not what it
accepts. OAuth scopes are not implemented, so no `scope` parameter is
advertised and no request is refused for want of one.

**Protocol versions.** The server simultaneously supports three revisions of
the MCP specification:
[`2025-03-26`](https://modelcontextprotocol.io/specification/2025-03-26),
Expand Down
4 changes: 2 additions & 2 deletions enterprise/e2e/auth-closed/hurl/denial.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ Content-Type: application/json
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry"
WWW-Authenticate: Bearer realm="registry", resource_metadata="{{base}}/.well-known/oauth-protected-resource/self/v1/mcp"
Link: </self/v1/schemas/api/error>; rel="describedby"
Access-Control-Allow-Origin: *
{
Expand Down Expand Up @@ -346,7 +346,7 @@ Content-Type: application/json
```
HTTP 401
Cache-Control: no-store
WWW-Authenticate: Bearer realm="registry"
WWW-Authenticate: Bearer realm="registry", resource_metadata="{{base}}/.well-known/oauth-protected-resource/self/v1/mcp"
Access-Control-Allow-Origin: *
[Asserts]
header "Access-Control-Expose-Headers" contains "WWW-Authenticate"
Expand Down
130 changes: 127 additions & 3 deletions enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
HTTP 200
Content-Type: application/json
Cache-Control: public, max-age=0, must-revalidate
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Link: </self/v1/schemas/mcp/prm/response>; rel="describedby"
Expand Down Expand Up @@ -65,23 +66,47 @@ GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
Authorization: Bearer full-secret-key
HTTP 200
Content-Type: application/json
Link: </self/v1/schemas/mcp/prm/response>; rel="describedby"
[Captures]
credentialed_body: body
{
"resource": "{{base}}/self/v1/mcp",
"authorization_servers": [ "https://keycloak:8443/realms/main" ],
"bearer_methods_supported": [ "header" ]
}

POST {{base}}/self/v1/api/schemas/evaluate{{document_schema}}
Authorization: Bearer full-secret-key
```
{{credentialed_body}}
```
HTTP 200
[Asserts]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This 401 response is produced with no Origin header on the request, but the server only emits Access-Control-Expose-Headers when an Origin is present (see json_error in src/http/include/sourcemeta/one/http_helpers.h). The new assertion header "Access-Control-Expose-Headers" contains "WWW-Authenticate" therefore cannot match and the test will fail. Send an Origin (matching the instance's allowed origin) on this request, as the OPTIONS case does, so the CORS header the assertion depends on is actually emitted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl, line 84:

<comment>This 401 response is produced with no Origin header on the request, but the server only emits `Access-Control-Expose-Headers` when an `Origin` is present (see json_error in src/http/include/sourcemeta/one/http_helpers.h). The new assertion `header "Access-Control-Expose-Headers" contains "WWW-Authenticate"` therefore cannot match and the test will fail. Send an `Origin` (matching the instance's allowed origin) on this request, as the OPTIONS case does, so the CORS header the assertion depends on is actually emitted.</comment>

<file context>
@@ -65,23 +66,47 @@ GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
+{{credentialed_body}}
+```
+HTTP 200
+[Asserts]
+jsonpath "$.valid" == true
+
</file context>

jsonpath "$.valid" == true

# A credential that belongs to no policy is not a reason to withhold it either
GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
Authorization: Bearer not-any-key
HTTP 200
Content-Type: application/json
Link: </self/v1/schemas/mcp/prm/response>; rel="describedby"
[Captures]
stranger_body: body
{
"resource": "{{base}}/self/v1/mcp",
"authorization_servers": [ "https://keycloak:8443/realms/main" ],
"bearer_methods_supported": [ "header" ]
}

POST {{base}}/self/v1/api/schemas/evaluate{{document_schema}}
Authorization: Bearer full-secret-key
```
{{stranger_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# Per RFC 9110 a HEAD carries the same headers and no body
HEAD {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
HTTP 200
Expand Down Expand Up @@ -129,7 +154,9 @@ HTTP 200
jsonpath "$.valid" == true

# The endpoint the document describes still refuses an anonymous caller, so
# publishing where a token comes from did not hand one out
# publishing where a token comes from did not hand one out. The refusal now
# names where one does come from, which is what saves a client from deriving
# the location and being wrong about it
POST {{base}}/self/v1/mcp
MCP-Protocol-Version: 2025-11-25
Content-Type: application/json
Expand All @@ -138,7 +165,104 @@ Content-Type: application/json
```
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry", resource_metadata="{{base}}/.well-known/oauth-protected-resource/self/v1/mcp"
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
mcp_denied_body: body
[Asserts]
header "Access-Control-Expose-Headers" contains "WWW-Authenticate"
{
"type": "urn:sourcemeta:one:authentication-required",
"title": "Unauthorized",
"status": 401,
"detail": "This resource requires authentication"
}

POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
Authorization: Bearer full-secret-key
```
{{mcp_denied_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# The trailing-slash spelling of the endpoint derives a trailing-slash location,
# so both are served rather than only the canonical one
POST {{base}}/self/v1/mcp/
MCP-Protocol-Version: 2025-11-25
Content-Type: application/json
```
{ "jsonrpc": "2.0", "id": 1, "method": "ping" }
```
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry", resource_metadata="{{base}}/.well-known/oauth-protected-resource/self/v1/mcp"
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
slash_denied_body: body
{
"type": "urn:sourcemeta:one:authentication-required",
"title": "Unauthorized",
"status": 401,
"detail": "This resource requires authentication"
}

POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
Authorization: Bearer full-secret-key
```
{{slash_denied_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp/
HTTP 200
Content-Type: application/json
Cache-Control: public, max-age=0, must-revalidate
Link: </self/v1/schemas/mcp/prm/response>; rel="describedby"
[Captures]
slash_body: body
{
"resource": "{{base}}/self/v1/mcp",
"authorization_servers": [ "https://keycloak:8443/realms/main" ],
"bearer_methods_supported": [ "header" ]
}

POST {{base}}/self/v1/api/schemas/evaluate{{document_schema}}
Authorization: Bearer full-secret-key
```
{{slash_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# A denial anywhere else carries no such pointer, since nothing else on this
# instance advertises an authorization server
GET {{base}}/catalog/classified.json
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry"
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
elsewhere_body: body
{
"type": "urn:sourcemeta:one:authentication-required",
"title": "Unauthorized",
"status": 401,
"detail": "This resource requires authentication"
}

POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
Authorization: Bearer full-secret-key
```
{{elsewhere_body}}
```
HTTP 200
[Asserts]
jsonpath "$.type" == "urn:sourcemeta:one:authentication-required"
jsonpath "$.status" == 401
jsonpath "$.valid" == true
2 changes: 1 addition & 1 deletion enterprise/e2e/auth-closed/hurl/mcp-scoped.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ Content-Type: application/json
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry"
WWW-Authenticate: Bearer realm="registry", resource_metadata="{{base}}/.well-known/oauth-protected-resource/self/v1/mcp"
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
wrong_body: body
Expand Down
68 changes: 68 additions & 0 deletions enterprise/e2e/html/hurl/mcp-prm.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
Authorization: Bearer whatever
HTTP 404
Content-Type: application/problem+json
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
credentialed_absent_body: body
{
"type": "urn:sourcemeta:one:not-found",
"title": "Not Found",
"status": 404,
"detail": "There is nothing at this URL"
}

POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
```
{{credentialed_absent_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

HEAD {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
HTTP 404
Content-Type: application/problem+json
Expand Down Expand Up @@ -88,10 +99,67 @@ jsonpath "$.valid" == true
# has a document to describe, since it is part of the catalog either way
GET {{base}}/self/v1/schemas/mcp/prm/response
HTTP 200
Cache-Control: public, max-age=0, must-revalidate
Content-Type: application/schema+json
[Asserts]
jsonpath "$['$schema']" == "https://json-schema.org/draft/2020-12/schema"
jsonpath "$.title" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.type" == "object"
jsonpath "$.required" includes "resource"
jsonpath "$.required" includes "authorization_servers"
jsonpath "$.required" includes "bearer_methods_supported"
jsonpath "$.additionalProperties" == false
jsonpath "$.properties.resource.format" == "uri"
jsonpath "$.properties.authorization_servers.items.format" == "uri"
jsonpath "$.properties.bearer_methods_supported.const" includes "header"

# This instance gates nothing, so the endpoint answers rather than denies, and
# it names no metadata because there is none to name
POST {{base}}/self/v1/mcp
MCP-Protocol-Version: 2025-11-25
Content-Type: application/json
```
{ "jsonrpc": "2.0", "id": 1, "method": "ping" }
```
HTTP 200
Content-Type: application/json
Link: </self/v1/schemas/mcp/response>; rel="describedby"
[Captures]
ping_body: body
mcp_schema: header "Link" regex "<([^>]+)>"
[Asserts]
header "WWW-Authenticate" not exists
{
"jsonrpc": "2.0",
"id": 1,
"result": {}
}

POST {{base}}/self/v1/api/schemas/evaluate{{mcp_schema}}
```
{{ping_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp/
HTTP 404
Content-Type: application/problem+json
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
slash_absent_body: body
{
"type": "urn:sourcemeta:one:not-found",
"title": "Not Found",
"status": 404,
"detail": "There is nothing at this URL"
}

POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
```
{{slash_absent_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ class ActionMCPProtectedResourceMetadata_v1

response.write_status(sourcemeta::core::HTTP_STATUS_OK);
response.write_header("Content-Type", "application/json");
// Public because it is served to anybody, and revalidated because a stale
// copy would name an authorization server this instance has stopped
// accepting, sending a client to obtain a token that would be refused
response.write_header(
"Cache-Control",
sourcemeta::one::RouterAction::content_cache_control(true));
// A browser-based client fetches this from its own origin, so it has to be
// readable across origins to be worth publishing at all
response.write_header("Access-Control-Allow-Origin", "*");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class ActionMCP_v1 : public sourcemeta::one::RouterAction {
identifier, [this](const auto &key, const auto &value) -> void {
if (key == "responseSchema") {
this->response_schema_ = std::get<std::string_view>(value);
} else if (key == "metadataPath") {
this->metadata_path_ = std::get<std::string_view>(value);
} else if (key == "requestSchema") {
this->request_schema_ = std::get<std::string_view>(value);
}
Expand All @@ -67,6 +69,22 @@ class ActionMCP_v1 : public sourcemeta::one::RouterAction {
// somehow carried a mixed-case origin through.
// https://datatracker.ietf.org/doc/html/rfc6454#section-4
assert(sourcemeta::core::is_lowercase(this->allowed_origin_));

// RFC 9728 Section 5.1. A client derives this location from the resource
// identifier anyway, but only after being refused once, and a deployment
// under a base path serves it somewhere that derivation cannot reach. It
// is named here only when there is a document to find
if (this->mcp_metadata_.defines("protectedResourceMetadata")) {
this->challenge_.append("resource_metadata=\"");
this->challenge_.append(this->server_uri());
this->challenge_.append(this->metadata_path_);
this->challenge_.append("\"");
}
}

[[nodiscard]] auto authentication_challenge() const noexcept
-> std::string_view override {
return this->challenge_;
}

auto rest(const std::span<std::string_view>, std::string_view credential,
Expand Down Expand Up @@ -704,7 +722,9 @@ class ActionMCP_v1 : public sourcemeta::one::RouterAction {
std::string_view allowed_origin_;
std::string_view response_schema_;
std::string_view request_schema_;
std::string_view metadata_path_;
sourcemeta::core::JSON mcp_metadata_{nullptr};
std::string challenge_;
sourcemeta::one::SearchView search_view_;
};

Expand Down
Loading
Loading