Skip to content

Advertise the protected resource metadata in the MCP challenge - #1186

Merged
jviotti merged 5 commits into
mainfrom
auth-mcp
Aug 1, 2026
Merged

Advertise the protected resource metadata in the MCP challenge#1186
jviotti merged 5 commits into
mainfrom
auth-mcp

Conversation

@jviotti

@jviotti jviotti commented Aug 1, 2026

Copy link
Copy Markdown
Member

Signed-off-by: Juan Cruz Viotti jv@jviotti.com

Review in cubic

Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>
Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>
@jviotti
jviotti marked this pull request as ready for review August 1, 2026 13:58
@augmentcode

augmentcode Bot commented Aug 1, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR makes MCP authentication denials more actionable by advertising RFC 9728 Protected Resource Metadata (PRM) via the MCP endpoint’s OAuth challenge.

Changes:

  • Document the MCP endpoint’s OAuth 2.1 resource-server behavior and how PRM is advertised
  • Serve PRM at the standard well-known location (including a trailing-slash variant) and mark it cacheable but revalidated
  • Extend RouterAction with an overridable authentication_challenge() hook
  • Plumb an optional challenge string into JSON 401 responses to append resource_metadata to WWW-Authenticate
  • Update enterprise/community E2E hurl tests to validate the new headers and schemas

Technical Notes: Only routes that can actually publish PRM add the resource_metadata parameter; other 401s remain Bearer realm="registry" only.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review completed. 1 suggestion posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread src/index/generators.h Outdated
{"requestSchema", std::string_view{mcp_request_schema}},
{"responseSchema", std::string_view{mcp_response_schema}}};
{"responseSchema", std::string_view{mcp_response_schema}},
{"metadataPath", std::string_view{mcp_metadata_path}}};

@augmentcode augmentcode Bot Aug 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

mcp_metadata_path is a temporary std::string but is stored into URITemplateRouter::Argument as a std::string_view; URITemplateRouter keeps argument values as string_view and requires the referenced storage to outlive the router. This can dangle and later produce an invalid metadataPath when ActionMCP_v1 reads route arguments (potentially breaking the WWW-Authenticate resource_metadata construction).

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test/e2e/html/hurl/mcp-prm.community.hurl">

<violation number="1" location="test/e2e/html/hurl/mcp-prm.community.hurl:115">
P3: The leading comment describes a "denial carrying no pointer," but the very next block is a successful ping (HTTP 200, `result: {}`), and every denial in this file (including the trailing-slash GET below) carries a `Link: ...rel="describedby"` header. The comment doesn't match the code it precedes, so it reads as a stale copy from another edition and misleads future readers; consider moving/rephrasing it or dropping it.</violation>
</file>

<file name="src/index/generators.h">

<violation number="1" location="src/index/generators.h:1030">
P1: Protected MCP endpoints under a non-root base path advertise a doubled base path, so OAuth clients cannot fetch `resource_metadata` after a 401. Compose this URL from the origin and RFC 9728 well-known path, or pass an argument that is relative to `server_uri()` without re-adding `configuration.base_path`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/index/generators.h Outdated
auth_callback_arguments);

const auto mcp_metadata_path{
configuration.base_path +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Protected MCP endpoints under a non-root base path advertise a doubled base path, so OAuth clients cannot fetch resource_metadata after a 401. Compose this URL from the origin and RFC 9728 well-known path, or pass an argument that is relative to server_uri() without re-adding configuration.base_path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/index/generators.h, line 1030:

<comment>Protected MCP endpoints under a non-root base path advertise a doubled base path, so OAuth clients cannot fetch `resource_metadata` after a 401. Compose this URL from the origin and RFC 9728 well-known path, or pass an argument that is relative to `server_uri()` without re-adding `configuration.base_path`.</comment>

<file context>
@@ -1026,9 +1026,13 @@ struct GENERATE_URITEMPLATE_ROUTES {
                  auth_callback_arguments);
 
+      const auto mcp_metadata_path{
+          configuration.base_path +
+          std::string{sourcemeta::one::ENDPOINT_MCP_PRM}};
       const sourcemeta::core::URITemplateRouter::Argument mcp_arguments[] = {
</file context>

jsonpath "$.properties.authorization_servers.items.format" == "uri"
jsonpath "$.properties.bearer_methods_supported.const" includes "header"

# Where no document is published the denial carries no pointer either, since

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The leading comment describes a "denial carrying no pointer," but the very next block is a successful ping (HTTP 200, result: {}), and every denial in this file (including the trailing-slash GET below) carries a Link: ...rel="describedby" header. The comment doesn't match the code it precedes, so it reads as a stale copy from another edition and misleads future readers; consider moving/rephrasing it or dropping it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/e2e/html/hurl/mcp-prm.community.hurl, line 115:

<comment>The leading comment describes a "denial carrying no pointer," but the very next block is a successful ping (HTTP 200, `result: {}`), and every denial in this file (including the trailing-slash GET below) carries a `Link: ...rel="describedby"` header. The comment doesn't match the code it precedes, so it reads as a stale copy from another edition and misleads future readers; consider moving/rephrasing it or dropping it.</comment>

<file context>
@@ -87,10 +98,67 @@ jsonpath "$.valid" == true
+jsonpath "$.properties.authorization_servers.items.format" == "uri"
+jsonpath "$.properties.bearer_methods_supported.const" includes "header"
+
+# Where no document is published the denial carries no pointer either, since
+# there would be nothing at the other end of it
+POST {{base}}/self/v1/mcp
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/index/generators.h">

<violation number="1" location="src/index/generators.h:847">
P1: Protected MCP challenges advertise a doubled base path for deployments mounted below `/`. For `https://host/v1/catalog`, this produces `https://host/v1/catalog/v1/catalog/.well-known/...`, so clients cannot fetch protected-resource metadata; construct the challenge URL from the origin plus this path, or pass a path not already prefixed.</violation>
</file>

<file name="src/http/include/sourcemeta/one/http_helpers.h">

<violation number="1" location="src/http/include/sourcemeta/one/http_helpers.h:194">
P1: Production builds can emit injected response headers when a future/configuration-derived challenge contains CR/LF, because the only validation is debug-only `assert`. Retain the default Bearer challenge when the added value is invalid rather than appending it verbatim.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/index/generators.h Outdated
const auto mcp_response_schema{configuration.base_path +
"/self/v1/schemas/mcp/response"};
const auto mcp_metadata_path{
configuration.base_path +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Protected MCP challenges advertise a doubled base path for deployments mounted below /. For https://host/v1/catalog, this produces https://host/v1/catalog/v1/catalog/.well-known/..., so clients cannot fetch protected-resource metadata; construct the challenge URL from the origin plus this path, or pass a path not already prefixed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/index/generators.h, line 847:

<comment>Protected MCP challenges advertise a doubled base path for deployments mounted below `/`. For `https://host/v1/catalog`, this produces `https://host/v1/catalog/v1/catalog/.well-known/...`, so clients cannot fetch protected-resource metadata; construct the challenge URL from the origin plus this path, or pass a path not already prefixed.</comment>

<file context>
@@ -843,6 +843,9 @@ struct GENERATE_URITEMPLATE_ROUTES {
     const auto mcp_response_schema{configuration.base_path +
                                    "/self/v1/schemas/mcp/response"};
+    const auto mcp_metadata_path{
+        configuration.base_path +
+        std::string{sourcemeta::one::ENDPOINT_MCP_PRM}};
     const auto mcp_protected_resource_metadata_response_schema{
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is a known limitation, though worth explaining in the docs maybe

Comment on lines +194 to +200
if (challenge.empty()) {
response.write_header("WWW-Authenticate", "Bearer realm=\"registry\"");
} else {
std::string value{"Bearer realm=\"registry\", "};
value.append(challenge);
response.write_header("WWW-Authenticate", value);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Production builds can emit injected response headers when a future/configuration-derived challenge contains CR/LF, because the only validation is debug-only assert. Retain the default Bearer challenge when the added value is invalid rather than appending it verbatim.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/http/include/sourcemeta/one/http_helpers.h, line 194:

<comment>Production builds can emit injected response headers when a future/configuration-derived challenge contains CR/LF, because the only validation is debug-only `assert`. Retain the default Bearer challenge when the added value is invalid rather than appending it verbatim.</comment>

<file context>
@@ -189,7 +191,13 @@ inline auto json_error(const HTTPRequest &request, HTTPResponse &response,
   // https://datatracker.ietf.org/doc/html/rfc6750#section-3
   if (status == sourcemeta::core::HTTP_STATUS_UNAUTHORIZED) {
-    response.write_header("WWW-Authenticate", "Bearer realm=\"registry\"");
+    if (challenge.empty()) {
+      response.write_header("WWW-Authenticate", "Bearer realm=\"registry\"");
+    } else {
</file context>
Suggested change
if (challenge.empty()) {
response.write_header("WWW-Authenticate", "Bearer realm=\"registry\"");
} else {
std::string value{"Bearer realm=\"registry\", "};
value.append(challenge);
response.write_header("WWW-Authenticate", value);
}
if (challenge.empty() ||
challenge.find_first_of("\r\n") != std::string_view::npos) {
response.write_header("WWW-Authenticate", "Bearer realm=\"registry\"");
} else {
std::string value{"Bearer realm=\"registry\", "};
value.append(challenge);
response.write_header("WWW-Authenticate", value);
}

Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl">

<violation number="1" location="enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl:84">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

{{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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Benchmark Index (enterprise)

Details
Benchmark suite Current: 349516f Previous: 4677568 Ratio
Add one schema (0 existing) 334 ms 398 ms 0.84
Add one schema (100 existing) 94 ms 108 ms 0.87
Add one schema (1000 existing) 159 ms 152 ms 1.05
Add one schema (10000 existing) 1057 ms 706 ms 1.50
Update one schema (1 existing) 82 ms 99 ms 0.83
Update one schema (101 existing) 94 ms 105 ms 0.90
Update one schema (1001 existing) 156 ms 156 ms 1
Update one schema (10001 existing) 1068 ms 719 ms 1.49
Cached rebuild (1 existing) 8 ms 6 ms 1.33
Cached rebuild (101 existing) 10 ms 8 ms 1.25
Cached rebuild (1001 existing) 35 ms 24 ms 1.46
Cached rebuild (10001 existing) 297 ms 208 ms 1.43
Index 100 schemas 735 ms 643 ms 1.14
Index 1000 schemas 1600 ms 1366 ms 1.17
Index 10000 schemas 12783 ms 11661 ms 1.10
Index 10000 schemas (custom meta-schema) 15914 ms 14517 ms 1.10
Index 10000 schemas ($ref fan-out) 14906 ms 14079 ms 1.06

This comment was automatically generated by workflow using github-action-benchmark.

Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 13 files

Re-trigger cubic

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Benchmark Index (community)

Details
Benchmark suite Current: 349516f Previous: 4677568 Ratio
Add one schema (0 existing) 327 ms 314 ms 1.04
Add one schema (100 existing) 30 ms 33 ms 0.91
Add one schema (1000 existing) 94 ms 104 ms 0.90
Add one schema (10000 existing) 796 ms 874 ms 0.91
Update one schema (1 existing) 21 ms 22 ms 0.95
Update one schema (101 existing) 29 ms 34 ms 0.85
Update one schema (1001 existing) 95 ms 104 ms 0.91
Update one schema (10001 existing) 791 ms 878 ms 0.90
Cached rebuild (1 existing) 8 ms 8 ms 1
Cached rebuild (101 existing) 10 ms 12 ms 0.83
Cached rebuild (1001 existing) 37 ms 42 ms 0.88
Cached rebuild (10001 existing) 320 ms 361 ms 0.89
Index 100 schemas 595 ms 590 ms 1.01
Index 1000 schemas 1346 ms 1477 ms 0.91
Index 10000 schemas 13699 ms 13733 ms 1.00
Index 10000 schemas (custom meta-schema) 16664 ms 16433 ms 1.01
Index 10000 schemas ($ref fan-out) 16608 ms 16445 ms 1.01

This comment was automatically generated by workflow using github-action-benchmark.

@jviotti
jviotti merged commit 77e37ed into main Aug 1, 2026
5 checks passed
@jviotti
jviotti deleted the auth-mcp branch August 1, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant