diff --git a/src/core/mcp/include/sourcemeta/core/mcp_results.h b/src/core/mcp/include/sourcemeta/core/mcp_results.h index 114ceb639..e7e3e43ca 100644 --- a/src/core/mcp/include/sourcemeta/core/mcp_results.h +++ b/src/core/mcp/include/sourcemeta/core/mcp_results.h @@ -944,6 +944,25 @@ auto mcp_write_result( std::optional cache_policy, std::optional server_info = std::nullopt) -> void; +/// @ingroup mcp +/// Serialize a complete MCP response for one of the six cacheable operations +/// using move semantics, decorating the provided result in-place. +/// A cache policy is mandatory for 2026-07-28. Pass `std::nullopt` for legacy +/// responses. A precomputed discovery result is held to the same rules as the +/// builder that produces one, so its advertised revisions must be non-empty, +/// must include the revision being served, and must each be one this library +/// models. +/// This writes JSON only. Stdio callers must append a newline themselves. +/// +/// @see +/// https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cacheable-results +SOURCEMETA_CORE_MCP_EXPORT +auto mcp_write_result( + std::ostream &stream, MCPProtocolVersion version, JSON::StringView method, + const JSON &identifier, JSON &&result, + std::optional cache_policy, + std::optional server_info = std::nullopt) -> void; + /// @ingroup mcp /// Build the JSON-RPC envelope returned in response to an MCP `initialize` /// request. The revision is the one the client asked for when the server diff --git a/src/core/mcp/notifications.cc b/src/core/mcp/notifications.cc index a4ae2bac2..111a4001f 100644 --- a/src/core/mcp/notifications.cc +++ b/src/core/mcp/notifications.cc @@ -206,20 +206,20 @@ auto mcp_make_notification(const MCPProtocolVersion version, } else if (method == MCP_METHOD_NOTIFICATIONS_MESSAGE) { const auto *level{ parameters.try_at("level", sourcemeta::core::MCP_HASH_LEVEL)}; + const auto resolved_level{(level != nullptr && level->is_string()) + ? mcp_resolve_log_level(level->to_string()) + : std::nullopt}; internal::require( - (level != nullptr) && level->is_string() && - internal::valid_log_level(level->to_string()) && + resolved_level.has_value() && parameters.defines("data", sourcemeta::core::MCP_HASH_DATA) && optional_string(parameters, "logger", sourcemeta::core::MCP_HASH_LOGGER), "Invalid logging notification"); if (version == MCPProtocolVersion::V_2026_07_28 && from_server) { const auto threshold{mcp_resolve_log_level(*request_context->log_level)}; - internal::require( - threshold.has_value() && - mcp_log_level_enabled(*mcp_resolve_log_level(level->to_string()), - *threshold), - "Logging notification is below the request threshold"); + internal::require(threshold.has_value() && + mcp_log_level_enabled(*resolved_level, *threshold), + "Logging notification is below the request threshold"); } } else if (method == MCP_METHOD_NOTIFICATIONS_CANCELLED) { const auto *identifier{ diff --git a/src/core/mcp/parameter_headers.cc b/src/core/mcp/parameter_headers.cc index a67114e4b..877989dfa 100644 --- a/src/core/mcp/parameter_headers.cc +++ b/src/core/mcp/parameter_headers.cc @@ -64,8 +64,15 @@ auto require_parameters(const std::span parameters) auto safe_parameter(const JSON &value, const MCPHeaderParameter ¶meter) -> bool { if (parameter.type == JSON::Type::Integer) { - return value.is_integral() && value >= JSON{INT64_C(-9007199254740991)} && - value <= JSON{INT64_C(9007199254740991)}; + if (value.is_integer()) { + const auto integer{value.to_integer()}; + return integer >= INT64_C(-9007199254740991) && + integer <= INT64_C(9007199254740991); + } + static const JSON MIN_SAFE_INTEGER{INT64_C(-9007199254740991)}; + static const JSON MAX_SAFE_INTEGER{INT64_C(9007199254740991)}; + return value.is_integral() && value >= MIN_SAFE_INTEGER && + value <= MAX_SAFE_INTEGER; } return value.type() == parameter.type; } diff --git a/src/core/mcp/results.cc b/src/core/mcp/results.cc index d5f521d30..eee63d2b7 100644 --- a/src/core/mcp/results.cc +++ b/src/core/mcp/results.cc @@ -207,14 +207,8 @@ namespace { // is reachable from the interim and subscription results too, whose own result // type rules out the complete-result decorator // https://modelcontextprotocol.io/specification/2026-07-28/basic/index#responses -auto attach_server_info(const MCPProtocolVersion version, - sourcemeta::core::JSON &result, - const std::optional &server_info) - -> void { - if (!server_info.has_value() || !mcp_requires_result_type(version)) { - return; - } - auto info{internal::make_implementation(version, *server_info)}; +auto attach_server_info(sourcemeta::core::JSON &result, + sourcemeta::core::JSON &&info) -> void { if (auto *meta{result.try_at("_meta", MCP_HASH_META)}; meta != nullptr && meta->is_object()) { // Caller-supplied metadata may already identify the server @@ -227,6 +221,17 @@ auto attach_server_info(const MCPProtocolVersion version, } } +auto attach_server_info(const MCPProtocolVersion version, + sourcemeta::core::JSON &result, + const std::optional &server_info) + -> void { + if (!server_info.has_value() || !mcp_requires_result_type(version)) { + return; + } + attach_server_info(result, + internal::make_implementation(version, *server_info)); +} + auto decorate_result( const MCPProtocolVersion version, sourcemeta::core::JSON &result, const std::optional &server_info = std::nullopt) @@ -256,12 +261,20 @@ auto decorate_result( "or extension results"); } + auto info{ + server_info.has_value() + ? std::optional{internal::make_implementation( + version, *server_info)} + : std::nullopt}; + if (result.try_at("resultType", MCP_HASH_RESULT_TYPE) == nullptr) { result.assign_assume_new("resultType", sourcemeta::core::JSON{"complete"}, MCP_HASH_RESULT_TYPE); } - attach_server_info(version, result, server_info); + if (info) { + attach_server_info(result, std::move(*info)); + } } } // namespace @@ -1066,11 +1079,11 @@ auto mcp_make_completion_result(const MCPProtocolVersion version, JSON values, return result; } -auto mcp_write_result(std::ostream &stream, const MCPProtocolVersion version, - const JSON::StringView method, const JSON &identifier, - const JSON &result, - const std::optional cache_policy, - const std::optional server_info) +namespace { +auto validate_write_result(const MCPProtocolVersion version, + const JSON::StringView method, + const JSON &identifier, const JSON &result, + const std::optional &cache_policy) -> void { internal::require(internal::valid_id(identifier), "Invalid MCP identifier"); internal::require(mcp_is_request_method(version, method) && @@ -1102,6 +1115,10 @@ auto mcp_write_result(std::ostream &stream, const MCPProtocolVersion version, internal::require(cache_policy.has_value(), "Cacheable results require a policy"); } + if (cache_policy) { + internal::require(cache_policy->ttl_ms >= 0, + "A cache time to live cannot be negative"); + } const JSON *entries = nullptr; if (method == MCP_METHOD_TOOLS_LIST) { entries = result.try_at("tools", sourcemeta::core::MCP_HASH_TOOLS); @@ -1164,11 +1181,46 @@ auto mcp_write_result(std::ostream &stream, const MCPProtocolVersion version, cursor) { internal::require(cursor->is_string(), "Invalid result cursor"); } - auto payload{result}; - decorate_result(version, payload, server_info); +} + +auto write_result_impl(std::ostream &stream, const MCPProtocolVersion version, + const JSON::StringView method, const JSON &identifier, + JSON &&result, + const std::optional &cache_policy, + const std::optional &server_info) + -> void { + // NOLINTNEXTLINE(performance-unnecessary-copy-initialization) + const auto envelope_identifier{identifier}; + const JSON::String operation{method}; + decorate_result(version, result, server_info); if (cache_policy) { - decorate_cacheable_result(version, payload, *cache_policy, method); + decorate_cacheable_result(version, result, *cache_policy, operation); } - stringify(jsonrpc_make_success(identifier, std::move(payload)), stream); + stringify(jsonrpc_make_success(envelope_identifier, std::move(result)), + stream); +} +} // namespace + +auto mcp_write_result(std::ostream &stream, const MCPProtocolVersion version, + const JSON::StringView method, const JSON &identifier, + JSON &&result, + const std::optional cache_policy, + const std::optional server_info) + -> void { + validate_write_result(version, method, identifier, result, cache_policy); + write_result_impl(stream, version, method, identifier, std::move(result), + cache_policy, server_info); +} + +auto mcp_write_result(std::ostream &stream, const MCPProtocolVersion version, + const JSON::StringView method, const JSON &identifier, + const JSON &result, + const std::optional cache_policy, + const std::optional server_info) + -> void { + validate_write_result(version, method, identifier, result, cache_policy); + auto payload{result}; + write_result_impl(stream, version, method, identifier, std::move(payload), + cache_policy, server_info); } } // namespace sourcemeta::core diff --git a/test/mcp/mcp_headers_test.cc b/test/mcp/mcp_headers_test.cc index 439c2b79e..95e317a71 100644 --- a/test/mcp/mcp_headers_test.cc +++ b/test/mcp/mcp_headers_test.cc @@ -2360,6 +2360,38 @@ TEST(make_parameter_headers_omits_an_absent_value) { // Integer values must stay within the safe range for JavaScript // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#schema-extension +TEST(make_parameter_headers_accepts_safe_integer_boundaries) { + const std::array descriptors{ + {{.name = "Count", + .path = {"number"}, + .type = sourcemeta::core::JSON::Type::Integer}}}; + const auto min_headers{sourcemeta::core::mcp_make_parameter_headers( + descriptors, sourcemeta::core::parse_json( + R"JSON({ "number": -9007199254740991 })JSON"))}; + const std::vector> expected_min{ + {"Mcp-Param-Count", "-9007199254740991"}}; + EXPECT_EQ(min_headers, expected_min); + + const auto max_headers{sourcemeta::core::mcp_make_parameter_headers( + descriptors, sourcemeta::core::parse_json( + R"JSON({ "number": 9007199254740991 })JSON"))}; + const std::vector> expected_max{ + {"Mcp-Param-Count", "9007199254740991"}}; + EXPECT_EQ(max_headers, expected_max); +} + +TEST(make_parameter_headers_rejects_integer_below_min_safe) { + const std::array descriptors{ + {{.name = "Count", + .path = {"number"}, + .type = sourcemeta::core::JSON::Type::Integer}}}; + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_make_parameter_headers( + descriptors, sourcemeta::core::parse_json( + R"JSON({ "number": -9007199254740992 })JSON")), + "Invalid mirrored argument type or unsafe integer"); +} + TEST(make_parameter_headers_rejects_an_unsafe_integer) { const std::array descriptors{ {{.name = "Count", diff --git a/test/mcp/mcp_results_test.cc b/test/mcp/mcp_results_test.cc index 17cc96287..9613afba1 100644 --- a/test/mcp/mcp_results_test.cc +++ b/test/mcp/mcp_results_test.cc @@ -6417,6 +6417,101 @@ TEST(write_result_resource_templates_list_2026_07_28) { "\"resultType\":\"complete\",\"ttlMs\":123,\"cacheScope\":\"public\"}}"); } +TEST(write_result_moves_rvalue_payload) { + std::ostringstream stream; + auto result{sourcemeta::core::parse_json(R"JSON({ "tools": [] })JSON")}; + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/list", + sourcemeta::core::JSON{1}, std::move(result), + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 123, .scope = sourcemeta::core::MCPCacheScope::Public}); + EXPECT_EQ( + stream.str(), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[],\"resultType\":" + "\"complete\",\"ttlMs\":123,\"cacheScope\":\"public\"}}"); +} + +TEST(write_result_preserves_const_lvalue_payload) { + std::ostringstream stream; + const auto result{sourcemeta::core::parse_json(R"JSON({ "tools": [] })JSON")}; + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/list", + sourcemeta::core::JSON{1}, result, + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 123, .scope = sourcemeta::core::MCPCacheScope::Public}); + EXPECT_EQ(result, sourcemeta::core::parse_json(R"JSON({ "tools": [] })JSON")); + EXPECT_EQ( + stream.str(), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[],\"resultType\":" + "\"complete\",\"ttlMs\":123,\"cacheScope\":\"public\"}}"); +} + +TEST(write_result_supports_aliased_identifier) { + std::ostringstream stream; + auto result{ + sourcemeta::core::parse_json(R"JSON({ "id": 1, "tools": [] })JSON")}; + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/list", + result.at("id"), std::move(result), + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 123, .scope = sourcemeta::core::MCPCacheScope::Public}); + EXPECT_EQ(stream.str(), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"id\":1,\"tools\":[]," + "\"resultType\":" + "\"complete\",\"ttlMs\":123,\"cacheScope\":\"public\"}}"); +} + +TEST(write_result_supports_aliased_method) { + std::ostringstream stream; + auto result{sourcemeta::core::parse_json( + R"JSON({ "op": "tools/list", "tools": [] })JSON")}; + const sourcemeta::core::JSON::StringView method_view{ + result.at("op").to_string()}; + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, method_view, + sourcemeta::core::JSON{1}, std::move(result), + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 123, .scope = sourcemeta::core::MCPCacheScope::Public}); + EXPECT_EQ( + stream.str(), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"op\":\"tools/list\"," + "\"tools\":[],\"resultType\":\"complete\",\"ttlMs\":123,\"cacheScope\":" + "\"public\"}}"); +} + +TEST(write_result_supports_aliased_server_info) { + std::ostringstream stream; + auto result{sourcemeta::core::parse_json( + R"JSON({ "srv": "my-server", "ver": "1.0", "tools": [] })JSON")}; + const sourcemeta::core::MCPImplementation server{ + .name = result.at("srv").to_string(), + .version = result.at("ver").to_string()}; + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, "tools/list", + sourcemeta::core::JSON{1}, std::move(result), + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 123, .scope = sourcemeta::core::MCPCacheScope::Public}, + server); + EXPECT_EQ( + stream.str(), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"srv\":\"my-server\"," + "\"ver\":\"1.0\",\"tools\":[],\"resultType\":\"complete\",\"_meta\":" + "{\"io.modelcontextprotocol/serverInfo\":{\"name\":\"my-server\"," + "\"version\":\"1.0\"}},\"ttlMs\":123,\"cacheScope\":\"public\"}}"); +} + +TEST(write_result_lvalue_rejects_invalid_identifier_before_copy) { + std::ostringstream stream; + const auto result{sourcemeta::core::parse_json(R"JSON({ "tools": [] })JSON")}; + EXPECT_MCP_REJECTS( + sourcemeta::core::mcp_write_result( + stream, sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + "tools/list", sourcemeta::core::JSON{nullptr}, result, + sourcemeta::core::MCPCachePolicy{ + .ttl_ms = 123, .scope = sourcemeta::core::MCPCacheScope::Public}), + "Invalid MCP identifier"); +} + TEST(write_result_prompts_list_2026_07_28) { std::ostringstream stream; sourcemeta::core::mcp_write_result(