Skip to content

Commit 653cbef

Browse files
committed
Serve RFC 9728 protected resource metadata for the MCP endpoint
Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>
1 parent a41c681 commit 653cbef

54 files changed

Lines changed: 4367 additions & 3106 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# A client that arrives holding nothing cannot be told to bring a credential
2+
# without also being told where one comes from. RFC 9728 publishes that at a
3+
# well-known location derived from the resource identifier, and this instance
4+
# declares a `jwt` policy over the MCP endpoint whose audience is that
5+
# identifier, so there is an authorization server it can honestly name.
6+
# https://datatracker.ietf.org/doc/html/rfc9728
7+
8+
# The document is readable with no credential, on an instance where everything
9+
# else demands one. That is the whole point rather than an oversight: gating it
10+
# would leave it reachable only by somebody who no longer needs to ask
11+
GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
12+
HTTP 200
13+
Content-Type: application/json
14+
Access-Control-Allow-Origin: *
15+
Access-Control-Expose-Headers: Link, ETag
16+
Link: </self/v1/schemas/mcp/protected-resource-metadata/response>; rel="describedby"
17+
[Captures]
18+
document_body: body
19+
document_schema: header "Link" regex "<([^>]+)>"
20+
{
21+
"resource": "{{base}}/self/v1/mcp",
22+
"authorization_servers": [ "https://keycloak:8443/realms/main" ],
23+
"bearer_methods_supported": [ "header" ]
24+
}
25+
26+
POST {{base}}/self/v1/api/schemas/evaluate{{document_schema}}
27+
Authorization: Bearer full-secret-key
28+
```
29+
{{document_body}}
30+
```
31+
HTTP 200
32+
[Asserts]
33+
jsonpath "$.valid" == true
34+
35+
# The instance is otherwise closed, so the contrast is what proves the
36+
# exemption rather than a missing policy
37+
GET {{base}}/catalog/example.json
38+
HTTP 401
39+
Cache-Control: no-store
40+
Content-Type: application/problem+json
41+
WWW-Authenticate: Bearer realm="registry"
42+
Link: </self/v1/schemas/api/error>; rel="describedby"
43+
[Captures]
44+
denied_body: body
45+
error_schema: header "Link" regex "<([^>]+)>"
46+
{
47+
"type": "urn:sourcemeta:one:authentication-required",
48+
"title": "Unauthorized",
49+
"status": 401,
50+
"detail": "This resource requires authentication"
51+
}
52+
53+
POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
54+
Authorization: Bearer full-secret-key
55+
```
56+
{{denied_body}}
57+
```
58+
HTTP 200
59+
[Asserts]
60+
jsonpath "$.valid" == true
61+
62+
# Presenting a credential changes nothing, since the document describes the
63+
# resource rather than the caller
64+
GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
65+
Authorization: Bearer full-secret-key
66+
HTTP 200
67+
Content-Type: application/json
68+
{
69+
"resource": "{{base}}/self/v1/mcp",
70+
"authorization_servers": [ "https://keycloak:8443/realms/main" ],
71+
"bearer_methods_supported": [ "header" ]
72+
}
73+
74+
# A credential that belongs to no policy is not a reason to withhold it either
75+
GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
76+
Authorization: Bearer not-any-key
77+
HTTP 200
78+
Content-Type: application/json
79+
{
80+
"resource": "{{base}}/self/v1/mcp",
81+
"authorization_servers": [ "https://keycloak:8443/realms/main" ],
82+
"bearer_methods_supported": [ "header" ]
83+
}
84+
85+
# Per RFC 9110 a HEAD carries the same headers and no body
86+
HEAD {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
87+
HTTP 200
88+
Content-Type: application/json
89+
Access-Control-Allow-Origin: *
90+
Link: </self/v1/schemas/mcp/protected-resource-metadata/response>; rel="describedby"
91+
[Asserts]
92+
bytes count == 0
93+
94+
# A browser-based client fetches this cross-origin, so the preflight has to
95+
# admit it
96+
OPTIONS {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
97+
Origin: http://example.com
98+
Access-Control-Request-Method: GET
99+
HTTP 204
100+
Cache-Control: no-store
101+
Access-Control-Allow-Origin: *
102+
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
103+
[Asserts]
104+
bytes count == 0
105+
106+
# The document is read, never written
107+
POST {{base}}/.well-known/oauth-protected-resource/self/v1/mcp
108+
HTTP 405
109+
Cache-Control: no-store
110+
Content-Type: application/problem+json
111+
Allow: GET, HEAD, OPTIONS
112+
Link: </self/v1/schemas/api/error>; rel="describedby"
113+
[Captures]
114+
method_body: body
115+
{
116+
"type": "urn:sourcemeta:one:method-not-allowed",
117+
"title": "Method Not Allowed",
118+
"status": 405,
119+
"detail": "This HTTP method is invalid for this URL"
120+
}
121+
122+
POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}}
123+
Authorization: Bearer full-secret-key
124+
```
125+
{{method_body}}
126+
```
127+
HTTP 200
128+
[Asserts]
129+
jsonpath "$.valid" == true
130+
131+
# The endpoint the document describes still refuses an anonymous caller, so
132+
# publishing where a token comes from did not hand one out
133+
POST {{base}}/self/v1/mcp
134+
MCP-Protocol-Version: 2025-11-25
135+
Content-Type: application/json
136+
```
137+
{ "jsonrpc": "2.0", "id": 1, "method": "ping" }
138+
```
139+
HTTP 401
140+
Cache-Control: no-store
141+
WWW-Authenticate: Bearer realm="registry"
142+
[Asserts]
143+
jsonpath "$.type" == "urn:sourcemeta:one:authentication-required"
144+
jsonpath "$.status" == 401

enterprise/e2e/auth-closed/one.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@
1919
"paths": [ "/self/v1/mcp" ],
2020
"keys": [ { "environmentVariable": "ONE_E2E_MCP_KEY" } ]
2121
},
22+
{
23+
"type": "jwt",
24+
"name": "mcp-tokens",
25+
"paths": [ "/self/v1/mcp" ],
26+
"issuer": "https://keycloak:8443/realms/main",
27+
"audience": "http://localhost:8000/self/v1/mcp",
28+
"algorithms": [ "RS256" ]
29+
},
2230
{
2331
"type": "jwt",
2432
"name": "machine",

enterprise/e2e/auth/hurl/directory.all.hurl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ header "ETag" exists
1616
jsonpath "$.path" == "/"
1717
jsonpath "$.url" == "{{base}}"
1818
jsonpath "$.breadcrumb" count == 0
19-
jsonpath "$.schemas" == 73
19+
jsonpath "$.schemas" == 74
2020
jsonpath "$.policies" count == 0
2121
jsonpath "$.entries" count == 12
2222
jsonpath "$.entries[0].name" == "console"

enterprise/e2e/html/hurl/list.all.hurl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ header "Last-Modified" exists
1919
jsonpath "$.path" == "/"
2020
jsonpath "$.url" == "{{base}}"
2121
jsonpath "$.breadcrumb" count == 0
22-
jsonpath "$.schemas" == 137
22+
jsonpath "$.schemas" == 138
2323
jsonpath "$.entries" count == 4
2424
jsonpath "$.policies" count == 0
2525
jsonpath "$.entries[0].name" == "no-blaze"
@@ -34,7 +34,7 @@ jsonpath "$.entries[1].title" == "Self"
3434
jsonpath "$.entries[1].description" == "The schemas that define the current version of this instance"
3535
jsonpath "$.entries[1].type" == "directory"
3636
jsonpath "$.entries[1].health" == 100
37-
jsonpath "$.entries[1].schemas" == 61
37+
jsonpath "$.entries[1].schemas" == 62
3838
jsonpath "$.entries[1].path" == "/self/"
3939
jsonpath "$.entries[2].name" == "test"
4040
jsonpath "$.entries[2].policies" count == 0

enterprise/e2e/html/hurl/mcp-2025-03-26.all.hurl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ jsonpath "$..mimeType" not exists
753753
variable "text" jsonpath "$.path" == "/"
754754
variable "text" jsonpath "$.url" == "{{base}}"
755755
variable "text" jsonpath "$.breadcrumb" count == 0
756-
variable "text" jsonpath "$.schemas" == 137
756+
variable "text" jsonpath "$.schemas" == 138
757757
variable "text" jsonpath "$.entries" count == 4
758758
variable "text" jsonpath "$.policies" count == 0
759759
variable "text" jsonpath "$.entries[0].name" == "no-blaze"
@@ -768,7 +768,7 @@ variable "text" jsonpath "$.entries[1].title" == "Self"
768768
variable "text" jsonpath "$.entries[1].description" == "The schemas that define the current version of this instance"
769769
variable "text" jsonpath "$.entries[1].type" == "directory"
770770
variable "text" jsonpath "$.entries[1].health" == 100
771-
variable "text" jsonpath "$.entries[1].schemas" == 61
771+
variable "text" jsonpath "$.entries[1].schemas" == 62
772772
variable "text" jsonpath "$.entries[1].path" == "/self/"
773773
variable "text" jsonpath "$.entries[2].name" == "test"
774774
variable "text" jsonpath "$.entries[2].policies" count == 0

enterprise/e2e/html/hurl/mcp-2025-06-18.all.hurl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -744,8 +744,8 @@ jsonpath "$.result.structuredContent.url" == "{{base}}"
744744
variable "text" jsonpath "$.url" == "{{base}}"
745745
jsonpath "$.result.structuredContent.breadcrumb" count == 0
746746
variable "text" jsonpath "$.breadcrumb" count == 0
747-
jsonpath "$.result.structuredContent.schemas" == 137
748-
variable "text" jsonpath "$.schemas" == 137
747+
jsonpath "$.result.structuredContent.schemas" == 138
748+
variable "text" jsonpath "$.schemas" == 138
749749
jsonpath "$.result.structuredContent.entries" count == 4
750750
jsonpath "$.result.structuredContent.policies" count == 0
751751
variable "text" jsonpath "$.entries" count == 4
@@ -774,8 +774,8 @@ jsonpath "$.result.structuredContent.entries[1].type" == "directory"
774774
variable "text" jsonpath "$.entries[1].type" == "directory"
775775
jsonpath "$.result.structuredContent.entries[1].health" == 100
776776
variable "text" jsonpath "$.entries[1].health" == 100
777-
jsonpath "$.result.structuredContent.entries[1].schemas" == 61
778-
variable "text" jsonpath "$.entries[1].schemas" == 61
777+
jsonpath "$.result.structuredContent.entries[1].schemas" == 62
778+
variable "text" jsonpath "$.entries[1].schemas" == 62
779779
jsonpath "$.result.structuredContent.entries[1].path" == "/self/"
780780
variable "text" jsonpath "$.entries[1].path" == "/self/"
781781
jsonpath "$.result.structuredContent.entries[2].name" == "test"

0 commit comments

Comments
 (0)