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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ jobs:
path: enterprise/e2e/public
edition: ${{ matrix.edition.name }}
if: matrix.edition.name == 'enterprise'
- name: E2E (enterprise/auth-mcp-path)
uses: ./.github/actions/e2e
with:
path: enterprise/e2e/auth-mcp-path
edition: ${{ matrix.edition.name }}
if: matrix.edition.name == 'enterprise'
- name: E2E (enterprise/auth)
uses: ./.github/actions/e2e
with:
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ ifeq ($(ENTERPRISE),ON)
./contrib/e2e-native.sh enterprise/e2e/html $(EDITION) $(SANDBOX_PORT)
./contrib/e2e-native.sh enterprise/e2e/path $(EDITION) $(SANDBOX_PORT)
./contrib/e2e-native.sh enterprise/e2e/public $(EDITION) $(SANDBOX_PORT)
./contrib/e2e-native.sh enterprise/e2e/auth-mcp-path $(EDITION) $(SANDBOX_PORT)
# The authentication sandboxes stand up an identity provider alongside the
# registry, so they only run under Docker Compose, never the native path
endif
Expand All @@ -103,6 +104,9 @@ ifeq ($(ENTERPRISE),ON)
$(MAKE) -C enterprise/e2e/html EDITION=$(EDITION)
$(MAKE) -C enterprise/e2e/path EDITION=$(EDITION)
$(MAKE) -C enterprise/e2e/public EDITION=$(EDITION)
# Discovery metadata needs a policy but never contacts its issuer, so this
# one needs no identity provider alongside it
$(MAKE) -C enterprise/e2e/auth-mcp-path EDITION=$(EDITION)
# The authentication sandboxes each stand up an identity provider alongside
# the registry, exercising both JWT and apiKey policies
$(MAKE) -C enterprise/e2e/auth EDITION=$(EDITION)
Expand Down
7 changes: 7 additions & 0 deletions enterprise/e2e/auth-mcp-path/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM one
COPY one.json .
COPY schemas schemas
RUN sourcemeta one.json --profile
RUN set -e && test -d "$SOURCEMETA_ONE_WORKDIR" && \
test -z "$(ls -A "$SOURCEMETA_ONE_WORKDIR")"
RUN rm -rf "$SOURCEMETA_ONE_WORKDIR"
1 change: 1 addition & 0 deletions enterprise/e2e/auth-mcp-path/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include ../../../test/e2e/common.mk
11 changes: 11 additions & 0 deletions enterprise/e2e/auth-mcp-path/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
sandbox:
build:
context: .
dockerfile: Dockerfile
args:
SOURCEMETA_ONE_SANDBOX_EDITION: ${EDITION}
environment:
- SOURCEMETA_ONE_PORT=8001
ports:
- "${PORT}:8001"
119 changes: 119 additions & 0 deletions enterprise/e2e/auth-mcp-path/hurl/mcp-prm.all.hurl
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# RFC 9728 forms the metadata location by inserting the well-known string
# between the host and the path, which places it above any configured base
# path. An instance served under one therefore publishes the document
# somewhere derivation cannot reach, and only the `WWW-Authenticate` header
# leads a client there. That makes the header the whole discovery mechanism
# here, and the URL it names has to be exactly where the document is.
#
# It once was not. The challenge was composed as origin plus base path plus
# well-known path, but the instance URL already carries the base path, so it
# advertised the base path twice and named a URL that resolves nowhere. Every
# check until then had been against a root deployment, where the two spellings
# happen to coincide. This sandbox exists so that they cannot diverge again.
# https://datatracker.ietf.org/doc/html/rfc9728

# The endpoint is gated, so an anonymous client is refused and told where a
# token comes from. The base path appears once
POST {{base}}/registry/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}}/registry/.well-known/oauth-protected-resource/self/v1/mcp"
Link: </registry/self/v1/schemas/api/error>; rel="describedby"
Access-Control-Allow-Origin: *
[Captures]
denied_body: body
error_schema: header "Link" regex "</registry([^>]+)>"
[Asserts]
header "WWW-Authenticate" not contains "/registry/registry/"
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}}/registry/self/v1/api/schemas/evaluate{{error_schema}}
```
{{denied_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# The URL that challenge names is where the document actually is, which is the
# property the doubled base path broke
GET {{base}}/registry/.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: *
Link: </registry/self/v1/schemas/mcp/prm/response>; rel="describedby"
[Captures]
document_body: body
document_schema: header "Link" regex "</registry([^>]+)>"
{
"resource": "{{base}}/registry/self/v1/mcp",
"authorization_servers": [ "https://accounts.example.com" ],
"bearer_methods_supported": [ "header" ]
}

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

# The resource identifier the document declares is the endpoint that refused
# the client, so a token requested for it is one this instance accepts
GET {{base}}/registry/.well-known/oauth-protected-resource/self/v1/mcp
HTTP 200
[Asserts]
jsonpath "$.resource" == "{{base}}/registry/self/v1/mcp"
jsonpath "$.resource" not contains "/registry/registry/"

# Where the specification would look, above the base path, there is nothing.
# That is the limitation the header exists to cover rather than an oversight
GET {{base}}/.well-known/oauth-protected-resource/registry/self/v1/mcp
HTTP 404

# The trailing-slash spelling is served under the base path too
GET {{base}}/registry/.well-known/oauth-protected-resource/self/v1/mcp/
HTTP 200
Content-Type: application/json
[Captures]
slash_body: body
{
"resource": "{{base}}/registry/self/v1/mcp",
"authorization_servers": [ "https://accounts.example.com" ],
"bearer_methods_supported": [ "header" ]
}

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

# Catalog content is public here, so the gate covers the MCP endpoint alone and
# the challenge above is not something every path carries
GET {{base}}/registry/catalog/example
HTTP 200
Content-Type: application/schema+json
[Asserts]
header "WWW-Authenticate" not exists
jsonpath "$['$schema']" == "https://json-schema.org/draft/2020-12/schema"
jsonpath "$['$id']" == "{{base}}/registry/catalog/example"
jsonpath "$.title" == "Example"
jsonpath "$.description" == "A schema served publicly while the MCP endpoint is gated"
jsonpath "$.type" == "string"
20 changes: 20 additions & 0 deletions enterprise/e2e/auth-mcp-path/one.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"url": "http://localhost:8000/registry",
"html": {
"name": "MCP Discovery Path Sandbox",
"description": "Protected resource metadata for an instance served under a base path"
},
"authentication": [
{
"type": "jwt",
"name": "mcp",
"paths": [ "/self/v1/mcp" ],
"issuer": "https://accounts.example.com",
"audience": "http://localhost:8000/registry/self/v1/mcp",
"algorithms": [ "RS256" ]
}
],
"contents": {
"catalog": { "path": "./schemas/catalog" }
}
}
6 changes: 6 additions & 0 deletions enterprise/e2e/auth-mcp-path/schemas/catalog/example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Example",
"description": "A schema served publicly while the MCP endpoint is gated",
"type": "string"
}
Loading