diff --git a/docs/api.md b/docs/api.md index 7e6e1b430..76df9e837 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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), diff --git a/enterprise/e2e/auth-closed/hurl/denial.all.hurl b/enterprise/e2e/auth-closed/hurl/denial.all.hurl index 13d14621f..1016ba4ae 100644 --- a/enterprise/e2e/auth-closed/hurl/denial.all.hurl +++ b/enterprise/e2e/auth-closed/hurl/denial.all.hurl @@ -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: ; rel="describedby" Access-Control-Allow-Origin: * { @@ -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" diff --git a/enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl b/enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl index 194e6c44e..e1d94116a 100644 --- a/enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl +++ b/enterprise/e2e/auth-closed/hurl/mcp-prm.all.hurl @@ -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: ; rel="describedby" @@ -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: ; 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] +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: ; 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 @@ -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 @@ -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: ; 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: ; 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: ; 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: ; 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 diff --git a/enterprise/e2e/auth-closed/hurl/mcp-scoped.all.hurl b/enterprise/e2e/auth-closed/hurl/mcp-scoped.all.hurl index c61660433..b2aae785b 100644 --- a/enterprise/e2e/auth-closed/hurl/mcp-scoped.all.hurl +++ b/enterprise/e2e/auth-closed/hurl/mcp-scoped.all.hurl @@ -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: ; rel="describedby" [Captures] wrong_body: body diff --git a/enterprise/e2e/html/hurl/mcp-prm.all.hurl b/enterprise/e2e/html/hurl/mcp-prm.all.hurl index e5156f675..d66bc2bc2 100644 --- a/enterprise/e2e/html/hurl/mcp-prm.all.hurl +++ b/enterprise/e2e/html/hurl/mcp-prm.all.hurl @@ -36,6 +36,9 @@ GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp Authorization: Bearer whatever HTTP 404 Content-Type: application/problem+json +Link: ; rel="describedby" +[Captures] +credentialed_absent_body: body { "type": "urn:sourcemeta:one:not-found", "title": "Not Found", @@ -43,6 +46,14 @@ Content-Type: application/problem+json "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 @@ -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: ; 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: ; 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 diff --git a/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_prm_v1.h b/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_prm_v1.h index e0c8fe13f..b882ab4d4 100644 --- a/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_prm_v1.h +++ b/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_prm_v1.h @@ -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", "*"); diff --git a/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_v1.h b/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_v1.h index 73caadf13..f00bbfd75 100644 --- a/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_v1.h +++ b/enterprise/server/include/sourcemeta/one/enterprise_server_action_mcp_v1.h @@ -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(value); + } else if (key == "metadataPath") { + this->metadata_path_ = std::get(value); } else if (key == "requestSchema") { this->request_schema_ = std::get(value); } @@ -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 credential, @@ -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_; }; diff --git a/src/http/include/sourcemeta/one/http_helpers.h b/src/http/include/sourcemeta/one/http_helpers.h index 83adde64a..d729ba311 100644 --- a/src/http/include/sourcemeta/one/http_helpers.h +++ b/src/http/include/sourcemeta/one/http_helpers.h @@ -140,13 +140,15 @@ inline auto json_error(const HTTPRequest &request, HTTPResponse &response, const std::string_view detail, const std::string_view schema, const std::string_view origin, - const std::string_view allow = {}) -> void { + const std::string_view allow = {}, + const std::string_view challenge = {}) -> void { // Header values are written to the wire verbatim. CR/LF would split // headers, enabling response header injection or CORS widening. Today's // callers pass string literals, but the asserts catch future untrusted // forwards. assert(origin.find_first_of("\r\n") == std::string_view::npos); assert(allow.find_first_of("\r\n") == std::string_view::npos); + assert(challenge.find_first_of("\r\n") == std::string_view::npos); const auto body{sourcemeta::core::http_make_problem_details( {.status = status, .type = type, .detail = detail})}; @@ -189,7 +191,16 @@ inline auto json_error(const HTTPRequest &request, HTTPResponse &response, // https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.2 // https://datatracker.ietf.org/doc/html/rfc6750#section-3 if (status == sourcemeta::core::HTTP_STATUS_UNAUTHORIZED) { - response.write_header("WWW-Authenticate", "Bearer realm=\"registry\""); + // A value carrying CR or LF would split the response into headers of the + // caller's choosing, so an unusable extension is dropped rather than sent + 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); + } } if (!schema.empty()) { write_link_header(response, schema); @@ -236,10 +247,13 @@ inline auto json_error(const HTTPRequest &request, HTTPResponse &response, inline auto json_error_unauthorized(const HTTPRequest &request, HTTPResponse &response, const std::string_view schema, - const std::string_view origin) -> void { + const std::string_view origin, + const std::string_view challenge = {}) + -> void { json_error(request, response, sourcemeta::core::HTTP_STATUS_UNAUTHORIZED, "urn:sourcemeta:one:authentication-required", - "This resource requires authentication", schema, origin); + "This resource requires authentication", schema, origin, {}, + challenge); } } // namespace sourcemeta::one diff --git a/src/index/endpoints.h b/src/index/endpoints.h index 48d4aabda..c627d048a 100644 --- a/src/index/endpoints.h +++ b/src/index/endpoints.h @@ -44,8 +44,10 @@ inline constexpr std::string_view ENDPOINT_MCP{"/self/v1/mcp"}; inline constexpr std::string_view ENDPOINT_MCP_TRAILING_SLASH{"/self/v1/mcp/"}; // RFC 9728 Section 3 forms this by inserting the well-known string between // the host and the path of the resource it describes -inline constexpr std::string_view ENDPOINT_MCP_PROTECTED_RESOURCE_METADATA{ +inline constexpr std::string_view ENDPOINT_MCP_PRM{ "/.well-known/oauth-protected-resource/self/v1/mcp"}; +inline constexpr std::string_view ENDPOINT_MCP_PRM_TRAILING_SLASH{ + "/.well-known/oauth-protected-resource/self/v1/mcp/"}; inline constexpr std::string_view ENDPOINT_API_NOT_FOUND{"/self/v1/api/{+any}"}; inline constexpr std::string_view ENDPOINT_STATIC{"/self/v1/static/{+path}"}; diff --git a/src/index/generators.h b/src/index/generators.h index bd20b4a4d..36d047333 100644 --- a/src/index/generators.h +++ b/src/index/generators.h @@ -1028,7 +1028,8 @@ struct GENERATE_URITEMPLATE_ROUTES { const sourcemeta::core::URITemplateRouter::Argument mcp_arguments[] = { {"requestSchema", std::string_view{mcp_request_schema}}, - {"responseSchema", std::string_view{mcp_response_schema}}}; + {"responseSchema", std::string_view{mcp_response_schema}}, + {"metadataPath", sourcemeta::one::ENDPOINT_MCP_PRM}}; router.add(sourcemeta::one::ENDPOINT_MCP, "handle_mcp_request", next_id++, sourcemeta::one::ACTION_TYPE_MCP_V1, mcp_arguments); router.add(sourcemeta::one::ENDPOINT_MCP_TRAILING_SLASH, @@ -1042,8 +1043,13 @@ struct GENERATE_URITEMPLATE_ROUTES { std::string_view{ mcp_protected_resource_metadata_response_schema}}}; router.add( - sourcemeta::one::ENDPOINT_MCP_PROTECTED_RESOURCE_METADATA, - "mcp_protected_resource_metadata", next_id++, + sourcemeta::one::ENDPOINT_MCP_PRM, "mcp_protected_resource_metadata", + next_id++, + sourcemeta::one::ACTION_TYPE_MCP_PROTECTED_RESOURCE_METADATA_V1, + mcp_protected_resource_metadata_arguments); + router.add( + sourcemeta::one::ENDPOINT_MCP_PRM_TRAILING_SLASH, + "mcp_protected_resource_metadata_trailing_slash", next_id++, sourcemeta::one::ACTION_TYPE_MCP_PROTECTED_RESOURCE_METADATA_V1, mcp_protected_resource_metadata_arguments); diff --git a/src/router/include/sourcemeta/one/router.h b/src/router/include/sourcemeta/one/router.h index 73caa00b1..9fa8cb8c8 100644 --- a/src/router/include/sourcemeta/one/router.h +++ b/src/router/include/sourcemeta/one/router.h @@ -149,6 +149,15 @@ class RouterAction { return false; } + // What this route adds to the challenge it is denied with, beyond the realm. + // A route whose credential comes from somewhere a caller has no way to guess + // says where that is here, so a denial is actionable rather than a dead end. + // The default adds nothing + [[nodiscard]] virtual auto authentication_challenge() const noexcept + -> std::string_view { + return {}; + } + // Serve, in place, the login page for an unauthenticated browser navigating // to a path an interactive policy governs, returning true when it wrote the // response so the caller stops. The page is a per-directory artifact resolved diff --git a/src/router/router.cc b/src/router/router.cc index 10d811085..9d4a095ad 100644 --- a/src/router/router.cc +++ b/src/router/router.cc @@ -138,8 +138,9 @@ auto Router::dispatch( return; } - sourcemeta::one::json_error_unauthorized(request, response, - this->default_error_schema_, "*"); + sourcemeta::one::json_error_unauthorized( + request, response, this->default_error_schema_, "*", + instance->authentication_challenge()); return; } diff --git a/test/e2e/html/hurl/mcp-prm.community.hurl b/test/e2e/html/hurl/mcp-prm.community.hurl index 4d667d64a..ebe19cf6b 100644 --- a/test/e2e/html/hurl/mcp-prm.community.hurl +++ b/test/e2e/html/hurl/mcp-prm.community.hurl @@ -35,6 +35,9 @@ GET {{base}}/.well-known/oauth-protected-resource/self/v1/mcp Authorization: Bearer whatever HTTP 403 Content-Type: application/problem+json +Link: ; rel="describedby" +[Captures] +credentialed_absent_body: body { "type": "urn:sourcemeta:one:enterprise-required", "title": "Forbidden", @@ -42,6 +45,14 @@ Content-Type: application/problem+json "detail": "This feature is only available in the Enterprise edition" } +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 403 Content-Type: application/problem+json @@ -87,10 +98,72 @@ jsonpath "$.valid" == true # even where no document is ever produced from it 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" + +# There is no Model Context Protocol server in this edition at all, which is +# the deeper reason there is no metadata describing one. Nothing is denied +# here, so no challenge names a document either +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: ; rel="describedby" +[Captures] +ping_body: body +mcp_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "WWW-Authenticate" not exists +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32000, + "message": "Enterprise edition required", + "data": "MCP support is only available in the Enterprise edition of Sourcemeta One" + } +} + +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 403 +Content-Type: application/problem+json +Link: ; rel="describedby" +[Captures] +slash_absent_body: body +{ + "type": "urn:sourcemeta:one:enterprise-required", + "title": "Forbidden", + "status": 403, + "detail": "This feature is only available in the Enterprise edition" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}} +``` +{{slash_absent_body}} +``` +HTTP 200 +[Asserts] +jsonpath "$.valid" == true