diff --git a/src/core/mcp/headers.cc b/src/core/mcp/headers.cc index b1e53ed67..5f774c80a 100644 --- a/src/core/mcp/headers.cc +++ b/src/core/mcp/headers.cc @@ -10,6 +10,7 @@ #include #include // std::find +#include // assert #include // std::optional, std::nullopt #include // std::span #include // std::string @@ -460,11 +461,11 @@ auto mcp_validate_request_headers( version, identifier, MCP_HEADER_PROTOCOL_VERSION, *protocol_version_header, mcp_protocol_version_string(version)); } - if (meta_status && *meta_status != MCPRequestMetaStatus::Valid) { - return mcp_make_error_request_meta(version, identifier, *meta_status, - *protocol_version_header, - supported_versions); - } + // Every other outcome was reported above, and the one left is only reached + // by a body version that names no revision carrying metadata, which the two + // checks above have already turned down by holding the body to the header + // and the header to the revision this session speaks + assert(!meta_status || *meta_status == MCPRequestMetaStatus::Valid); if (!method_header || !sourcemeta::core::mcp_is_plain_header_value(*method_header)) { return mcp_make_error_header_mismatch(version, identifier, diff --git a/src/core/mcp/validation.h b/src/core/mcp/validation.h index cb6461bb4..53accb05a 100644 --- a/src/core/mcp/validation.h +++ b/src/core/mcp/validation.h @@ -11,7 +11,6 @@ #include #include // std::all_of -#include // std::isfinite #include // std::initializer_list #include // std::set #include // std::move, std::pair @@ -189,9 +188,7 @@ inline auto annotations(const JSON &value, const bool received = false) if (const auto *priority{ field(*item, "priority", sourcemeta::core::MCP_HASH_PRIORITY)}; priority != nullptr && - (!priority->is_number() || - (priority->is_real() && !std::isfinite(priority->to_real())) || - *priority < JSON{0} || *priority > JSON{1})) { + (!priority->is_number() || *priority < JSON{0} || *priority > JSON{1})) { return false; } if (const auto *audience{ @@ -1149,9 +1146,6 @@ inline auto valid_input_response(const JSON::StringView method, return false; } for (const auto &entry : content->as_object()) { - if (entry.second.is_real() && !std::isfinite(entry.second.to_real())) { - return false; - } if (!entry.second.is_string() && !entry.second.is_number() && !entry.second.is_boolean() && !validation::strings(entry.second)) { return false; diff --git a/test/mcp/mcp_error_test.cc b/test/mcp/mcp_error_test.cc index 6bc24b516..0b22b8151 100644 --- a/test/mcp/mcp_error_test.cc +++ b/test/mcp/mcp_error_test.cc @@ -1034,9 +1034,13 @@ TEST(error_code_to_http_status_method_not_found_2026_07_28) { // travels on a 200, so there is no status to report for any code // https://modelcontextprotocol.io/specification/2025-11-25/basic/transports TEST(error_code_to_http_status_method_not_found_2025_11_25) { + // The revision is held in a variable that is not itself constant, as a + // mapping handed nothing but constants is answered where it is written + // rather than where it is defined + sourcemeta::core::MCPProtocolVersion version{ + sourcemeta::core::MCPProtocolVersion::V_2025_11_25}; EXPECT_FALSE(sourcemeta::core::mcp_error_code_to_http_status( - sourcemeta::core::MCPProtocolVersion::V_2025_11_25, - sourcemeta::core::JSONRPC_CODE_METHOD_NOT_FOUND) + version, sourcemeta::core::JSONRPC_CODE_METHOD_NOT_FOUND) .has_value()); } @@ -1132,8 +1136,9 @@ TEST(error_code_to_http_status_resource_not_found) { } TEST(error_code_to_http_status_unmapped_code) { + std::int64_t code{-99999}; EXPECT_FALSE(sourcemeta::core::mcp_error_code_to_http_status( - sourcemeta::core::MCPProtocolVersion::V_2026_07_28, -99999) + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, code) .has_value()); } diff --git a/test/mcp/mcp_headers_test.cc b/test/mcp/mcp_headers_test.cc index ef080e9aa..439c2b79e 100644 --- a/test/mcp/mcp_headers_test.cc +++ b/test/mcp/mcp_headers_test.cc @@ -3677,3 +3677,326 @@ TEST(validate_request_headers_accepts_resources_unsubscribe_on_2025_11_25) { envelope, SUPPORTED) .has_value()); } + +// MCP writes a metadata key as an optional reverse-DNS prefix, a solidus and a +// name, so a key that opens with the separator names no prefix at all and one +// that carries a second separator names more than the grammar allows +TEST(request_meta_validation_rejects_a_key_opening_with_a_separator) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { "/name": true } } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_meta(envelope).first, + sourcemeta::core::MCPRequestMetaStatus::InvalidMetaKey); +} + +TEST(request_meta_validation_rejects_a_key_with_two_separators) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { "example.com/a/b": true } } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_meta(envelope).first, + sourcemeta::core::MCPRequestMetaStatus::InvalidMetaKey); +} + +// Each label of the prefix stands between dots, so a dot with no label before +// it, a label opening with something other than a letter, a character the +// grammar does not admit, and a prefix closing on a dot all name no prefix +TEST(request_meta_validation_rejects_a_prefix_opening_with_a_dot) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { ".example/name": true } } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_meta(envelope).first, + sourcemeta::core::MCPRequestMetaStatus::InvalidMetaKey); +} + +TEST(request_meta_validation_rejects_a_prefix_label_opening_with_a_digit) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { "1example/name": true } } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_meta(envelope).first, + sourcemeta::core::MCPRequestMetaStatus::InvalidMetaKey); +} + +TEST(request_meta_validation_rejects_a_prefix_carrying_an_underscore) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { "exam_ple/name": true } } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_meta(envelope).first, + sourcemeta::core::MCPRequestMetaStatus::InvalidMetaKey); +} + +TEST(request_meta_validation_rejects_a_prefix_closing_on_a_dot) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { "example./name": true } } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_meta(envelope).first, + sourcemeta::core::MCPRequestMetaStatus::InvalidMetaKey); +} + +// A value that cannot travel in a header as it stands is carried base64 +// encoded between the markers, so what the comparison sees is what the +// encoding spells out rather than the encoded form itself +TEST(parameter_string_accepts_an_encoded_value) { + const std::array descriptors{ + {{.name = "Value", + .path = {"value"}, + .type = sourcemeta::core::JSON::Type::String}}}; + auto arguments{sourcemeta::core::JSON::make_object()}; + arguments.assign("value", sourcemeta::core::JSON{"hello"}); + const std::array, + 1> + views{{{"Mcp-Param-Value", "=?base64?aGVsbG8=?="}}}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, arguments, views) + .has_value()); +} + +TEST(parameter_string_rejects_an_encoded_value_that_is_not_base64) { + const std::array descriptors{ + {{.name = "Value", + .path = {"value"}, + .type = sourcemeta::core::JSON::Type::String}}}; + auto arguments{sourcemeta::core::JSON::make_object()}; + arguments.assign("value", sourcemeta::core::JSON{"hello"}); + const std::array, + 1> + views{{{"Mcp-Param-Value", "=?base64?!!!!?="}}}; + EXPECT_TRUE(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, arguments, views) + .has_value()); +} + +// A header value carrying a byte no header may hold says nothing the +// comparison can read, encoded or not +TEST(parameter_string_rejects_a_value_with_a_forbidden_byte) { + const std::array descriptors{ + {{.name = "Value", + .path = {"value"}, + .type = sourcemeta::core::JSON::Type::String}}}; + auto arguments{sourcemeta::core::JSON::make_object()}; + arguments.assign("value", sourcemeta::core::JSON{"hello"}); + const std::array, + 1> + views{{{"Mcp-Param-Value", "hel\x01lo"}}}; + EXPECT_TRUE(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, arguments, views) + .has_value()); +} + +// A resource operation names what it acts on through its uri, so one whose +// parameters carry no such name leaves the request unnamed rather than +// falling back on a name meant for other operations +TEST(request_name_from_body_of_a_resource_read_without_a_uri) { + const auto envelope{sourcemeta::core::parse_json( + R"JSON({ + "method": "resources/read", + "params": { "name": "not-a-uri" } + })JSON")}; + EXPECT_FALSE( + sourcemeta::core::mcp_request_name_from_body(envelope).has_value()); +} + +// An operation that is not one of the resource ones is named by whichever of +// the two it carries, so one carrying only a uri is named by that +TEST(request_name_from_body_of_a_subscription_listen_with_a_uri) { + const auto envelope{sourcemeta::core::parse_json( + R"JSON({ + "method": "subscriptions/listen", + "params": { "uri": "file:///a" } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_request_name_from_body(envelope).value(), + "file:///a"); +} + +// A protocol version travels as a header value, so one carrying a byte no +// header may hold names no revision at all +TEST(legacy_headers_reject_a_protocol_version_with_a_forbidden_byte) { + constexpr std::array SUPPORTED{ + {"2025-03-26", "2025-06-18", "2025-11-25"}}; + const auto envelope{sourcemeta::core::parse_json( + R"JSON({ "jsonrpc": "2.0", "id": 0, "method": "tools/list" })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_headers( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, + "2025-\x01-26", "ignored", "ignored", envelope, SUPPORTED) + .value() + .at("error") + .at("message") + .to_string(), + "Invalid protocol version header"); +} + +// A revision the server knows is still the wrong one to name once a session +// has settled on another +TEST(legacy_headers_reject_a_protocol_version_other_than_the_session_one) { + constexpr std::array SUPPORTED{ + {"2025-03-26", "2025-06-18", "2025-11-25"}}; + const auto envelope{sourcemeta::core::parse_json( + R"JSON({ "jsonrpc": "2.0", "id": 0, "method": "tools/list" })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_headers( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, + "2025-06-18", "ignored", "ignored", envelope, SUPPORTED) + .value() + .at("error") + .at("message") + .to_string(), + "Protocol version disagrees with session"); +} + +// The name a request routes on travels in its own header, which a duplicate +// of any routing header turns down before the request is read +TEST(validate_request_headers_reject_a_duplicate_name_header) { + constexpr std::array SUPPORTED{ + {"2026-07-28"}}; + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 0, "method": "tools/call", + "params": { + "name": "weather", + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })JSON")}; + const std::vector> + headers{{"mcp-protocol-version", "2026-07-28"}, + {"Mcp-Method", "tools/call"}, + {"Mcp-Name", "weather"}, + {"mcp-name", "weather"}}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, headers, + envelope, SUPPORTED) + .value() + .at("error") + .at("message") + .to_string(), + "Duplicate routing header"); +} + +// Before the routing headers existed, naming the protocol twice was a +// malformed request rather than a routing conflict +TEST( + validate_request_headers_reject_a_duplicate_protocol_header_on_2025_11_25) { + constexpr std::array SUPPORTED{ + {"2025-11-25", "2026-07-28"}}; + const auto envelope{sourcemeta::core::parse_json( + R"JSON({ "jsonrpc": "2.0", "id": 0, "method": "tools/list" })JSON")}; + const std::vector> + headers{{"mcp-protocol-version", "2025-11-25"}, + {"MCP-Protocol-Version", "2025-11-25"}}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_headers( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, headers, + envelope, SUPPORTED) + .value() + .at("error") + .at("message") + .to_string(), + "Duplicate protocol header"); +} + +// A revision the deployment lists as supported is still the wrong one to name +// once this session speaks another, which is settled after the header and the +// body have been held to each other +TEST(validate_request_headers_reject_a_version_the_session_does_not_speak) { + constexpr std::array SUPPORTED{ + {"2024-01-01", "2026-07-28"}}; + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 0, "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2024-01-01", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_validate_request_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "2024-01-01", "tools/list", std::nullopt, envelope, SUPPORTED) + .value() + .at("error") + .at("code"), + sourcemeta::core::JSON{-32020}); +} + +// The arguments a call mirrors into headers are the object the call was made +// with, so anything else mirrors nothing +TEST(parameter_headers_reject_arguments_that_are_not_an_object) { + const std::array descriptors{ + {{.name = "Value", + .path = {"value"}, + .type = sourcemeta::core::JSON::Type::String}}}; + EXPECT_EQ(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, + sourcemeta::core::JSON{42}, {}) + .value() + .at("error") + .at("message") + .to_string(), + "Invalid tool arguments"); +} + +// A nested parameter is reached by walking the path it names, which stops at +// the first step that holds no further members, leaving the parameter absent +TEST(parameter_headers_ignore_a_path_through_a_non_object) { + const std::array descriptors{ + {{.name = "Value", + .path = {"a", "b"}, + .type = sourcemeta::core::JSON::Type::String}}}; + auto arguments{sourcemeta::core::JSON::make_object()}; + arguments.assign("a", sourcemeta::core::JSON{42}); + EXPECT_FALSE(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, arguments, {}) + .has_value()); +} + +// A header claiming a parameter the call never passed mirrors an argument +// that is not there +TEST(parameter_headers_reject_a_header_for_an_absent_parameter) { + const std::array descriptors{ + {{.name = "Value", + .path = {"a", "b"}, + .type = sourcemeta::core::JSON::Type::String}}}; + auto arguments{sourcemeta::core::JSON::make_object()}; + arguments.assign("a", sourcemeta::core::JSON{42}); + const std::array, + 1> + views{{{"Mcp-Param-Value", "anything"}}}; + EXPECT_TRUE(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, arguments, views) + .has_value()); +} + +// An integer parameter is mirrored as the number it is, so a header that +// spells no number at all matches nothing +TEST(parameter_integer_rejects_a_header_that_is_not_a_number) { + const std::array descriptors{ + {{.name = "Value", + .path = {"value"}, + .type = sourcemeta::core::JSON::Type::Integer}}}; + auto arguments{sourcemeta::core::JSON::make_object()}; + arguments.assign("value", sourcemeta::core::JSON{42}); + const std::array, + 1> + views{{{"Mcp-Param-Value", "forty two"}}}; + EXPECT_TRUE(sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, descriptors, arguments, views) + .has_value()); +} diff --git a/test/mcp/mcp_notifications_test.cc b/test/mcp/mcp_notifications_test.cc index b7552968c..b6a014611 100644 --- a/test/mcp/mcp_notifications_test.cc +++ b/test/mcp/mcp_notifications_test.cc @@ -1434,3 +1434,161 @@ TEST(validate_continuation_requires_a_present_empty_state_for_an_empty_echo) { sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, "")); } + +// A tool result block names the call it answers and carries the content that +// answers it, so a block missing either names no result +// https://modelcontextprotocol.io/specification/2026-07-28/client/sampling +TEST(validate_continuation_rejects_a_tool_result_without_content) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { "s1": { + "role": "assistant", + "model": "a-model", + "content": { "type": "tool_result", "toolUseId": "t1" } + } } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ + "s1": { "method": "sampling/createMessage" } + })JSON")}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} + +TEST(validate_continuation_rejects_a_tool_result_holding_a_bad_block) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { "s1": { + "role": "assistant", + "model": "a-model", + "content": { + "type": "tool_result", + "toolUseId": "t1", + "content": [ { "type": "text" } ] + } + } } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ + "s1": { "method": "sampling/createMessage" } + })JSON")}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} + +// The prior request a response answers is an object naming the method it was +// sent for, so an entry that is anything else describes no request to match +// the response against +TEST(validate_continuation_rejects_a_prior_request_that_is_not_an_object) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { "s1": { "action": "decline" } } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ "s1": 42 })JSON")}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} + +// A listing of roots is an array of them, so a response that carries no such +// listing answers nothing that was asked +TEST(validate_continuation_rejects_a_roots_response_without_roots) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { "r": {} } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ + "r": { "method": "roots/list" } + })JSON")}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} + +// What a form comes back with is a flat answer per field, so a field answered +// with a structure is not an answer the form asked for +TEST(validate_continuation_rejects_a_structured_elicitation_answer) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { + "form": { "action": "accept", "content": { "x": { "y": 1 } } } + } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ + "form": { + "method": "elicitation/create", + "params": { "mode": "form", "message": "ask" } + } + })JSON")}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} + +// Only the three operations that may ask for input have an answer to read, so +// anything else has no answer to be held against +TEST(validate_continuation_rejects_a_response_to_an_unknown_request) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { "r": {} } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ + "r": { "method": "tools/list" } + })JSON")}; + EXPECT_FALSE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} + +// What a tool handed back may point at a resource, and a link arriving from +// the client is read as the text it is rather than being resolved here +TEST(validate_continuation_accepts_a_received_resource_link) { + const auto parameters{sourcemeta::core::parse_json(R"JSON({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }, + "inputResponses": { + "s": { + "role": "assistant", + "model": "m", + "content": { + "type": "tool_result", + "toolUseId": "t", + "content": [ + { + "type": "resource_link", + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain" + } + ] + } + } + } + })JSON")}; + const auto prior{sourcemeta::core::parse_json(R"JSON({ + "s": { "method": "sampling/createMessage" } + })JSON")}; + EXPECT_TRUE(sourcemeta::core::mcp_validate_continuation( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, parameters, prior, + std::nullopt)); +} diff --git a/test/mcp/mcp_protocol_test.cc b/test/mcp/mcp_protocol_test.cc index e0a481755..959bcf3fb 100644 --- a/test/mcp/mcp_protocol_test.cc +++ b/test/mcp/mcp_protocol_test.cc @@ -3624,3 +3624,96 @@ TEST(supports_method_on_parsed_input) { EXPECT_TRUE(sourcemeta::core::mcp_supports_method( version.value(), document.at("method").to_string())); } + +// The name is held in a variable rather than written out at the call, as a +// predicate handed nothing but constants is answered where it is written +// rather than where it is defined +TEST(no_revision_claims_an_unknown_request_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_request_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(no_revision_claims_an_unknown_server_request_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_server_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_server_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_server_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_server_request_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(no_revision_claims_an_unknown_notification_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(no_revision_claims_an_unknown_input_request_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_input_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_input_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_input_request_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_input_request_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(no_revision_supports_an_unknown_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_supports_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_supports_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_supports_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_supports_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(no_revision_claims_an_unknown_client_notification_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_client_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_client_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_client_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_client_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(no_revision_claims_an_unknown_server_notification_method) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_server_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_server_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_server_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25, unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_server_notification_method( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, unknown)); +} + +TEST(an_unknown_method_is_neither_retriable_nor_cacheable) { + const std::string unknown{"nothing/here"}; + EXPECT_FALSE(sourcemeta::core::mcp_is_mrtr_method(unknown)); + EXPECT_FALSE(sourcemeta::core::mcp_is_cacheable_method(unknown)); +} diff --git a/test/mcp/mcp_results_test.cc b/test/mcp/mcp_results_test.cc index 050d7ba24..17cc96287 100644 --- a/test/mcp/mcp_results_test.cc +++ b/test/mcp/mcp_results_test.cc @@ -8854,3 +8854,874 @@ TEST(make_initialize_result_returns_invalid_request_when_params_is_an_array) { EXPECT_EQ(envelope.at("error").at("code").to_integer(), sourcemeta::core::JSONRPC_CODE_INVALID_REQUEST); } + +TEST(make_resources_list_with_a_priority_above_the_interval) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_resources_list_result( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, + sourcemeta::core::parse_json(R"JSON([ { + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain", + "annotations": { "priority": 2 } + } ])JSON"), + std::nullopt, {}), + "Invalid MCP resource descriptor entry"); +} + +TEST(make_resources_list_with_a_priority_that_is_not_a_number) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_resources_list_result( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, + sourcemeta::core::parse_json(R"JSON([ { + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain", + "annotations": { "priority": "high" } + } ])JSON"), + std::nullopt, {}), + "Invalid MCP resource descriptor entry"); +} + +TEST(make_resources_list_with_an_audience_that_is_not_a_list) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_resources_list_result( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, + sourcemeta::core::parse_json(R"JSON([ { + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain", + "annotations": { "audience": "user" } + } ])JSON"), + std::nullopt, {}), + "Invalid MCP resource descriptor entry"); +} + +TEST(make_resources_list_with_an_audience_role_the_protocol_lacks) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_resources_list_result( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, + sourcemeta::core::parse_json(R"JSON([ { + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain", + "annotations": { "audience": [ "administrator" ] } + } ])JSON"), + std::nullopt, {}), + "Invalid MCP resource descriptor entry"); +} + +TEST(make_resources_list_with_icons_that_are_not_a_list) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_resources_list_result( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, + sourcemeta::core::parse_json(R"JSON([ { + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain", + "icons": { "src": "file:///icon.png" } + } ])JSON"), + std::nullopt, {}), + "Invalid MCP resource descriptor entry"); +} + +TEST(make_resources_list_with_icon_sizes_that_are_not_strings) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_resources_list_result( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18, + sourcemeta::core::parse_json(R"JSON([ { + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain", + "icons": [ { "src": "file:///icon.png", "sizes": [ 48 ] } ] + } ])JSON"), + std::nullopt, {}), + "Invalid MCP resource descriptor entry"); +} + +TEST(input_required_with_messages_that_are_not_a_list) { + const sourcemeta::core::JSON identifier{1}; + auto input_requests{sourcemeta::core::JSON::make_object()}; + input_requests.assign("s1", sourcemeta::core::parse_json(R"JSON({ + "method": "sampling/createMessage", + "params": { "messages": "hello", "maxTokens": 10 } + })JSON")); + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/call", + identifier, std::move(input_requests), "state-token", + sourcemeta::core::MCPClientCapabilities{.sampling = true}), + "Invalid or unsupported nested input requests"); +} + +TEST(input_required_with_a_message_role_the_protocol_lacks) { + const sourcemeta::core::JSON identifier{1}; + auto input_requests{sourcemeta::core::JSON::make_object()}; + input_requests.assign("s1", sourcemeta::core::parse_json(R"JSON({ + "method": "sampling/createMessage", + "params": { + "messages": [ + { "role": "system", "content": { "type": "text", "text": "x" } } + ], + "maxTokens": 10 + } + })JSON")); + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/call", + identifier, std::move(input_requests), "state-token", + sourcemeta::core::MCPClientCapabilities{.sampling = true}), + "Invalid or unsupported nested input requests"); +} + +TEST(input_required_with_a_system_prompt_that_is_not_a_string) { + const sourcemeta::core::JSON identifier{1}; + auto input_requests{sourcemeta::core::JSON::make_object()}; + input_requests.assign("s1", sourcemeta::core::parse_json(R"JSON({ + "method": "sampling/createMessage", + "params": { + "systemPrompt": 1, + "messages": [ + { "role": "user", "content": { "type": "text", "text": "x" } } + ], + "maxTokens": 10 + } + })JSON")); + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/call", + identifier, std::move(input_requests), "state-token", + sourcemeta::core::MCPClientCapabilities{.sampling = true}), + "Invalid or unsupported nested input requests"); +} + +TEST(input_required_with_stop_sequences_that_are_not_strings) { + const sourcemeta::core::JSON identifier{1}; + auto input_requests{sourcemeta::core::JSON::make_object()}; + input_requests.assign("s1", sourcemeta::core::parse_json(R"JSON({ + "method": "sampling/createMessage", + "params": { + "stopSequences": [ 1 ], + "messages": [ + { "role": "user", "content": { "type": "text", "text": "x" } } + ], + "maxTokens": 10 + } + })JSON")); + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/call", + identifier, std::move(input_requests), "state-token", + sourcemeta::core::MCPClientCapabilities{.sampling = true}), + "Invalid or unsupported nested input requests"); +} + +// A tool call must be answered by the message that immediately follows it, so +// a turn that answers only some of the calls it was given leaves the rest +// unresolved and the conversation incomplete +// https://modelcontextprotocol.io/specification/2026-07-28/client/sampling +TEST(input_required_with_a_tool_call_left_unanswered) { + const sourcemeta::core::JSON identifier{1}; + auto input_requests{sourcemeta::core::JSON::make_object()}; + input_requests.assign("s1", sourcemeta::core::parse_json(R"JSON({ + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "t1", "name": "a", "input": {} }, + { "type": "tool_use", "id": "t2", "name": "b", "input": {} } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "toolUseId": "t1", "content": [] } + ] + } + ] + } + })JSON")); + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/call", + identifier, std::move(input_requests), "state-token", + sourcemeta::core::MCPClientCapabilities{.sampling = true, + .sampling_tools = true}), + "Invalid or unsupported nested input requests"); +} + +// What a retried request carries is read only off a request that could be +// retried in the first place, so an envelope with no usable identifier, one +// whose method is not retriable, and one carrying no parameters all answer +// with nothing rather than reaching for members that may not be there +TEST(mrtr_accessors_ignore_an_envelope_without_an_identifier) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "method": "tools/call", + "params": { "inputResponses": {}, "requestState": "s" } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_request_input_responses( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope), + nullptr); + EXPECT_FALSE(sourcemeta::core::mcp_request_state( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope) + .has_value()); +} + +TEST(mrtr_accessors_ignore_a_method_that_cannot_be_retried) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "inputResponses": {}, "requestState": "s" } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_request_input_responses( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope), + nullptr); + EXPECT_FALSE(sourcemeta::core::mcp_request_state( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope) + .has_value()); +} + +TEST(mrtr_accessors_ignore_an_envelope_whose_parameters_are_not_an_object) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": [] + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_request_input_responses( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope), + nullptr); + EXPECT_FALSE(sourcemeta::core::mcp_request_state( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope) + .has_value()); +} + +// Both members are optional, so parameters that carry neither, or carry them +// as something other than what they are read as, leave nothing to hand back +TEST(mrtr_accessors_ignore_members_of_the_wrong_shape) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "inputResponses": 42, "requestState": 42 } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_request_input_responses( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope), + nullptr); + EXPECT_FALSE(sourcemeta::core::mcp_request_state( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, envelope) + .has_value()); +} + +// A discovery result may carry instructions for the client, which are text, +// so one carrying them as anything else describes nothing to follow +TEST(write_result_server_discover_rejects_non_string_instructions) { + std::ostringstream stream; + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "server/discover", sourcemeta::core::JSON{1}, + sourcemeta::core::parse_json(R"JSON({ + "supportedVersions": [ "2026-07-28" ], + "capabilities": {}, + "instructions": 42 + })JSON"), + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 0, .scope = sourcemeta::core::MCPCacheScope::Private}), + "Invalid discovery instructions"); +} + +// The arguments of a tool call are read only off a tool call, so an envelope +// with no usable identifier and one naming another operation both answer with +// nothing +TEST(tool_call_arguments_ignore_an_envelope_without_an_identifier) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "method": "tools/call", + "params": { "name": "foo", "arguments": {} } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_tool_call_arguments(envelope), nullptr); +} + +TEST(tool_call_arguments_ignore_another_operation) { + const auto envelope{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "name": "foo", "arguments": {} } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_tool_call_arguments(envelope), nullptr); +} + +// A client names itself alongside what it supports, so a handshake whose +// client is described as something other than an implementation is turned +// down once the revision to answer in has been settled +TEST(make_initialize_result_malformed_client_info_is_invalid_params) { + constexpr std::array SUPPORTED{ + {"2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"}}; + const auto request{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "clientInfo": { "version": "1.0.0" }, + "capabilities": {} + } + })JSON")}; + const sourcemeta::core::MCPServerCapabilities capabilities; + const sourcemeta::core::MCPImplementation server{.name = "srv", + .version = "1.0.0", + .title = {}, + .description = {}, + .website_url = {}}; + const auto expected{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "error": { "code": -32602, "message": "Invalid params" } + })JSON")}; + EXPECT_EQ(sourcemeta::core::mcp_make_initialize_response( + request, capabilities, server, SUPPORTED), + expected); +} + +// A server may picture itself with icons, which travel alongside the rest of +// what it says about itself +TEST(make_initialize_result_carries_server_icons) { + constexpr std::array SUPPORTED{ + {"2025-11-25"}}; + const auto request{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": {} + } + })JSON")}; + const auto icons{sourcemeta::core::parse_json(R"JSON([ + { "src": "https://example.com/a.png", "mimeType": "image/png" } + ])JSON")}; + const sourcemeta::core::MCPServerCapabilities capabilities; + const sourcemeta::core::MCPImplementation server{.name = "srv", + .version = "1.0.0", + .title = {}, + .description = {}, + .website_url = {}, + .icons = &icons}; + EXPECT_EQ(sourcemeta::core::mcp_make_initialize_response( + request, capabilities, server, SUPPORTED) + .at("result") + .at("serverInfo") + .at("icons"), + icons); +} + +// An icon says where it is drawn from, so one that says nothing pictures +// nothing +TEST(make_initialize_result_rejects_a_server_icon_without_a_source) { + constexpr std::array SUPPORTED{ + {"2025-11-25"}}; + const auto request{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": {} + } + })JSON")}; + const auto icons{sourcemeta::core::parse_json( + R"JSON([ { "mimeType": "image/png" } ])JSON")}; + const sourcemeta::core::MCPServerCapabilities capabilities; + const sourcemeta::core::MCPImplementation server{.name = "srv", + .version = "1.0.0", + .title = {}, + .description = {}, + .website_url = {}, + .icons = &icons}; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_initialize_response( + request, capabilities, server, SUPPORTED), + "Invalid MCP implementation information"); +} + +// A client's title is text, so a handshake describing it as anything else +// describes no client +TEST(make_initialize_result_non_string_client_title_is_invalid_params) { + constexpr std::array SUPPORTED{ + {"2025-11-25"}}; + const auto request{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "clientInfo": { "name": "client", "version": "1.0.0", "title": 42 }, + "capabilities": {} + } + })JSON")}; + const sourcemeta::core::MCPServerCapabilities capabilities; + const sourcemeta::core::MCPImplementation server{.name = "srv", + .version = "1.0.0", + .title = {}, + .description = {}, + .website_url = {}}; + EXPECT_EQ(sourcemeta::core::mcp_make_initialize_response( + request, capabilities, server, SUPPORTED) + .at("error") + .at("message") + .to_string(), + "Invalid params"); +} + +// The nested requests are named one by one, so anything that does not name +// them carries no request at all +TEST(input_required_result_rejects_input_requests_that_are_not_an_object) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.elicitation = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON([])JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// A nested request is named by the envelope that carries it, so it names no +// identifier and no protocol of its own +TEST(input_required_result_rejects_a_nested_request_with_an_identifier) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.roots = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { "method": "roots/list", "id": 1 } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// A server only asks for what the client said it could answer +TEST(input_required_result_rejects_roots_without_the_capability) { + sourcemeta::core::MCPClientCapabilities capabilities; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { "method": "roots/list" } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// What the user is being asked is the one thing an elicitation cannot leave +// out +TEST(input_required_result_rejects_an_elicitation_without_a_message) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.elicitation = true; + capabilities.elicitation_form = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "elicitation/create", + "params": { + "requestedSchema": { "type": "object", "properties": {} } + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// An elicitation is answered either in a form or at an address, and naming +// any other way of asking names none of them +TEST(input_required_result_rejects_an_unknown_elicitation_mode) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.elicitation = true; + capabilities.elicitation_form = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "elicitation/create", + "params": { "mode": "chat", "message": "ask" } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// Sending the user out of band is its own capability, which a client that +// never claimed it cannot be asked to act on +TEST(input_required_result_rejects_a_url_elicitation_without_the_capability) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.elicitation = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "elicitation/create", + "params": { + "mode": "url", + "message": "visit", + "url": "https://example.com" + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// A client may say which models it would rather be served by, naming each as +// a hint the server is free to read or ignore +TEST(input_required_result_accepts_sampling_model_hints) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + EXPECT_EQ(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "modelPreferences": { + "costPriority": 0.5, + "hints": [ { "name": "claude" } ] + }, + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities) + .at("result") + .at("resultType"), + sourcemeta::core::JSON{"input_required"}); +} + +TEST(input_required_result_rejects_sampling_hints_that_are_not_an_array) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "modelPreferences": { "hints": "claude" }, + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// A hint hints at a model by naming it, so one naming nothing hints at +// nothing +TEST(input_required_result_rejects_a_sampling_hint_without_a_name) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "modelPreferences": { "hints": [ { "name": 42 } ] }, + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +TEST(input_required_result_rejects_sampling_tools_that_are_not_an_array) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + capabilities.sampling_tools = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "tools": {}, + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +TEST(input_required_result_rejects_a_malformed_sampling_tool) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + capabilities.sampling_tools = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "tools": [ { "description": "no name" } ], + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// How much surrounding conversation to draw on is named out of a closed set +TEST(input_required_result_rejects_a_non_string_include_context) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + capabilities.sampling_context = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "includeContext": 42, + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +TEST(input_required_result_rejects_an_unknown_include_context) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + capabilities.sampling_context = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "includeContext": "everywhere", + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// Drawing on anything beyond the request itself is its own capability, so a +// client that never claimed it is only ever asked for none +TEST(input_required_result_rejects_include_context_without_the_capability) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "includeContext": "thisServer", + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// A link stands in for a resource without carrying it, so it is held to the +// same description the resource itself would be +TEST(tool_success_accepts_a_resource_link_block) { + EXPECT_EQ(sourcemeta::core::mcp_make_tool_success( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, + sourcemeta::core::JSON::make_object(), + sourcemeta::core::parse_json(R"JSON([ + { + "type": "resource_link", + "uri": "file:///a", + "name": "a", + "mimeType": "text/plain" + } + ])JSON")) + .at("result") + .at("content") + .at(0) + .at("type"), + sourcemeta::core::JSON{"resource_link"}); +} + +// What a resource is written in is a media type, so a link describing it as +// something that names no type describes nothing +TEST(tool_success_rejects_a_resource_link_with_a_malformed_media_type) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_tool_success( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, + sourcemeta::core::JSON::make_object(), + sourcemeta::core::parse_json(R"JSON([ + { "type": "resource_link", "uri": "file:///a", "name": "a", + "mimeType": "not a media type" } + ])JSON")), + "Invalid MCP content block"); +} + +TEST(tool_success_rejects_a_content_block_that_is_not_an_object) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_tool_success( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, + sourcemeta::core::JSON::make_object(), + sourcemeta::core::parse_json(R"JSON([ 42 ])JSON")), + "Invalid MCP content block"); +} + +// Each hint answers yes or no about how a tool behaves +TEST(tools_list_rejects_a_non_boolean_tool_hint) { + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_tools_list_result( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::parse_json(R"JSON([ + { + "name": "weather", + "inputSchema": { "type": "object" }, + "annotations": { "readOnlyHint": "yes" } + } + ])JSON"), + std::nullopt, {}), + "Invalid MCP tool descriptor entry"); +} + +// What a client can be asked for is described one operation at a time, each +// as its own object +TEST(make_initialize_result_rejects_malformed_client_request_capabilities) { + constexpr std::array SUPPORTED{ + {"2025-11-25"}}; + const auto request{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": { "tasks": { "requests": { "sampling": 42 } } } + } + })JSON")}; + const sourcemeta::core::MCPServerCapabilities capabilities; + const sourcemeta::core::MCPImplementation server{.name = "srv", + .version = "1.0.0", + .title = {}, + .description = {}, + .website_url = {}}; + EXPECT_EQ(sourcemeta::core::mcp_make_initialize_response( + request, capabilities, server, SUPPORTED) + .at("error") + .at("message") + .to_string(), + "Invalid params"); +} + +TEST(make_initialize_result_rejects_malformed_client_elicitation_capability) { + constexpr std::array SUPPORTED{ + {"2025-06-18"}}; + const auto request{sourcemeta::core::parse_json(R"JSON({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": { "elicitation": 42 } + } + })JSON")}; + const sourcemeta::core::MCPServerCapabilities capabilities; + const sourcemeta::core::MCPImplementation server{.name = "srv", + .version = "1.0.0", + .title = {}, + .description = {}, + .website_url = {}}; + EXPECT_EQ(sourcemeta::core::mcp_make_initialize_response( + request, capabilities, server, SUPPORTED) + .at("error") + .at("message") + .to_string(), + "Invalid params"); +} + +// Every content block says what kind it is before anything else about it can +// be read +TEST(tool_success_rejects_a_content_block_without_a_type) { + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_tool_success( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, sourcemeta::core::JSON::make_object(), + sourcemeta::core::parse_json(R"JSON([ { "text": "hi" } ])JSON")), + "Invalid MCP content block"); +} + +// Offering the model tools to call is its own capability +TEST(input_required_result_rejects_sampling_tools_without_the_capability) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.sampling = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { + "method": "sampling/createMessage", + "params": { + "maxTokens": 10, + "tools": [], + "messages": [ + { "role": "user", "content": { "type": "text", "text": "hi" } } + ] + } + } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +} + +// Only three operations ask the client for input, so nesting any other one +// asks for nothing it could answer +TEST(input_required_result_rejects_a_nested_request_of_another_operation) { + sourcemeta::core::MCPClientCapabilities capabilities; + capabilities.roots = true; + capabilities.sampling = true; + capabilities.elicitation = true; + EXPECT_MCP_REJECTS(sourcemeta::core::mcp_make_input_required_response( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/call", sourcemeta::core::JSON{0}, + sourcemeta::core::parse_json(R"JSON({ + "r": { "method": "tools/list" } + })JSON"), + std::nullopt, capabilities), + "Invalid or unsupported nested input requests"); +}