diff --git a/benchmark/CMakeLists.txt b/benchmark/CMakeLists.txt index b4aedefec..655cfe1d0 100644 --- a/benchmark/CMakeLists.txt +++ b/benchmark/CMakeLists.txt @@ -1,5 +1,9 @@ set(BENCHMARK_SOURCES) +if(SOURCEMETA_CORE_MCP) + list(APPEND BENCHMARK_SOURCES mcp.cc) +endif() + if(SOURCEMETA_CORE_REGEX) list(APPEND BENCHMARK_SOURCES regex.cc) endif() @@ -66,6 +70,11 @@ if(BENCHMARK_SOURCES) target_compile_definitions(sourcemeta_core_benchmarks PRIVATE CURRENT_DIRECTORY="${CMAKE_CURRENT_SOURCE_DIR}") + if(SOURCEMETA_CORE_MCP) + target_link_libraries(sourcemeta_core_benchmarks + PRIVATE sourcemeta::core::mcp) + endif() + if(SOURCEMETA_CORE_REGEX) target_link_libraries(sourcemeta_core_benchmarks PRIVATE sourcemeta::core::regex) diff --git a/benchmark/mcp.cc b/benchmark/mcp.cc new file mode 100644 index 000000000..6430f8f89 --- /dev/null +++ b/benchmark/mcp.cc @@ -0,0 +1,93 @@ +#include +#include + +#include // std::size_t +#include // std::runtime_error +#include // std::string, std::to_string +#include // std::pair, std::move +#include // std::vector + +namespace { +using namespace sourcemeta::core; + +auto validate_headers(BenchmarkState &state, const std::size_t count) -> void { + std::vector names; + names.reserve(count); + auto arguments{JSON::make_object()}; + for (std::size_t index{0}; index < count; ++index) { + names.push_back("field" + std::to_string(index)); + arguments.assign(names.back(), JSON{index % 2 == 0 ? "plain" : " café "}); + } + + std::vector parameters; + parameters.reserve(count); + for (const auto &name : names) { + parameters.push_back( + {.name = name, .path = {name}, .type = JSON::Type::String}); + } + + // Put unrelated headers first to exercise the per-parameter linear search. + std::vector> owned; + owned.reserve(count * 2); + for (std::size_t index{0}; index < count; ++index) { + owned.emplace_back("Other-" + std::to_string(index), "ignored"); + } + for (auto &header : mcp_make_parameter_headers(parameters, arguments)) { + owned.push_back(std::move(header)); + } + std::vector> headers; + headers.reserve(owned.size()); + for (const auto &header : owned) { + headers.emplace_back(header.first, header.second); + } + + constexpr auto VERSION{MCPProtocolVersion::V_2026_07_28}; + const JSON identifier{1}; + if (mcp_validate_parameter_headers(VERSION, identifier, parameters, arguments, + headers)) { + throw std::runtime_error{"Invalid MCP header benchmark fixture"}; + } + for (auto iteration : state) { + const auto result{mcp_validate_parameter_headers( + VERSION, identifier, parameters, arguments, headers)}; + benchmark_do_not_optimize(result); + } +} +} // namespace + +BENCHMARK(MCP_Validate_Headers_2) { validate_headers(state, 2); } +BENCHMARK(MCP_Validate_Headers_64) { validate_headers(state, 64); } + +BENCHMARK(MCP_Intersect_Disjoint_512) { + using namespace sourcemeta::core; + std::vector requested; + std::vector supported; + requested.reserve(512); + supported.reserve(512); + for (std::size_t index{0}; index < 512; ++index) { + requested.push_back("file:///bench/" + std::to_string(index)); + supported.push_back("file:///bench/" + std::to_string(index + 512)); + } + + MCPSubscriptionFilter left; + MCPSubscriptionFilter right; + left.resource_subscriptions.emplace(); + right.resource_subscriptions.emplace(); + for (const auto &uri : requested) { + left.resource_subscriptions->push_back(uri); + } + for (const auto &uri : supported) { + right.resource_subscriptions->push_back(uri); + } + + // No matches forces a complete supported-list scan for each requested URI. + const auto control{mcp_intersect_subscription_filters(left, right)}; + if (!control.resource_subscriptions || + !control.resource_subscriptions->empty()) { + throw std::runtime_error{"Invalid MCP subscription benchmark fixture"}; + } + for (auto iteration : state) { + const auto result{mcp_intersect_subscription_filters(left, right)}; + benchmark_do_not_optimize(result); + } +} diff --git a/cmake/common/targets/executable.cmake b/cmake/common/targets/executable.cmake index d4d253136..2e9c2d2b0 100644 --- a/cmake/common/targets/executable.cmake +++ b/cmake/common/targets/executable.cmake @@ -36,6 +36,13 @@ function(sourcemeta_executable) # program already calls if(SOURCEMETA_CORE_ALLOCATOR_TARGET) target_link_libraries("${TARGET_NAME}" PRIVATE ${SOURCEMETA_CORE_ALLOCATOR_TARGET}) + if(SOURCEMETA_OS_LINUX AND NOT BUILD_SHARED_LIBS AND Mimalloc_FOUND) + # Load the replacement entry points before the static C++ runtime can + # supply its own new/delete definitions. Override allocator references on + # this executable, including explicit allocator links from its caller. + set_property(TARGET "${TARGET_NAME}" PROPERTY + "LINK_LIBRARY_OVERRIDE_${SOURCEMETA_CORE_ALLOCATOR_TARGET}" WHOLE_ARCHIVE) + endif() endif() # See https://best.openssf.org/Compiler-Hardening-Guides/Compiler-Options-Hardening-Guide-for-C-and-C++.html diff --git a/src/core/mcp/parameter_headers.cc b/src/core/mcp/parameter_headers.cc index 877989dfa..3f433fed7 100644 --- a/src/core/mcp/parameter_headers.cc +++ b/src/core/mcp/parameter_headers.cc @@ -7,13 +7,16 @@ #include #include -#include // std::set -#include // std::string, std::to_string -#include // std::vector +#include // std::sort, std::lower_bound, std::lexicographical_compare +#include // std::next +#include // std::set +#include // std::string, std::to_string +#include // std::vector namespace { using sourcemeta::core::JSON; using sourcemeta::core::MCPHeaderParameter; +constexpr JSON::StringView PARAMETER_HEADER_PREFIX{"Mcp-Param-"}; auto value_at(const JSON &arguments, const MCPHeaderParameter ¶meter) -> const JSON * { @@ -33,12 +36,23 @@ auto value_at(const JSON &arguments, const MCPHeaderParameter ¶meter) // The specification constrains each mirrored parameter name, type and location // separately, so each constraint reports itself // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#schema-extension +struct NameLess { + auto operator()(const JSON::StringView left, + const JSON::StringView right) const -> bool { + return std::lexicographical_compare( + left.begin(), left.end(), right.begin(), right.end(), + [](const char left_character, const char right_character) { + return sourcemeta::core::to_lowercase(left_character) < + sourcemeta::core::to_lowercase(right_character); + }); + } +}; + auto require_parameters(const std::span parameters) -> void { - std::set names; + std::set names; + std::size_t index{0}; for (const auto ¶meter : parameters) { - std::string name{parameter.name}; - sourcemeta::core::to_lowercase(name); // A name must be non-empty and match HTTP field-name token syntax, which // also rules out control characters sourcemeta::core::internal::require( @@ -55,9 +69,21 @@ auto require_parameters(const std::span parameters) parameter.type == JSON::Type::Integer || parameter.type == JSON::Type::Boolean, "Parameter headers only mirror strings, integers and booleans"); + bool unique{true}; + if (parameters.size() <= 8) { + for (std::size_t previous{0}; previous < index; ++previous) { + if (sourcemeta::core::equals_ignore_case(parameters[previous].name, + parameter.name)) { + unique = false; + break; + } + } + } else { + unique = names.insert(parameter.name).second; + } + ++index; sourcemeta::core::internal::require( - names.insert(std::move(name)).second, - "Parameter header names must be case-insensitively unique"); + unique, "Parameter header names must be case-insensitively unique"); } } @@ -120,7 +146,8 @@ auto mcp_make_parameter_headers( if (const auto *value{value_at(arguments, parameter)}; value) { internal::require(safe_parameter(*value, parameter), "Invalid mirrored argument type or unsafe integer"); - result.emplace_back("Mcp-Param-" + std::string{parameter.name}, + result.emplace_back(std::string{PARAMETER_HEADER_PREFIX} + + std::string{parameter.name}, mcp_encode_header_value(parameter_text(*value))); } } @@ -141,24 +168,61 @@ auto mcp_validate_parameter_headers( return mcp_make_error(version, &identifier, JSONRPC_CODE_INVALID_PARAMS, "Invalid tool arguments"); } + // Index only larger collections. Small requests keep the allocation-free + // scan; unrelated headers and duplicates of unused names remain opaque. + std::vector> indexed; + const bool use_index{parameters.size() > 8 && headers.size() > 16}; + if (use_index) { + indexed.reserve(headers.size()); + for (const auto &entry : headers) { + if (starts_with_ignore_case(entry.first, PARAMETER_HEADER_PREFIX)) { + indexed.emplace_back(entry.first.substr(PARAMETER_HEADER_PREFIX.size()), + entry.second); + } + } + std::sort(indexed.begin(), indexed.end(), + [](const auto &left, const auto &right) { + return NameLess{}(left.first, right.first); + }); + } for (const auto ¶meter : parameters) { - const std::string name{"Mcp-Param-" + std::string{parameter.name}}; - std::optional header; - for (const auto &candidate : headers) { - if (equals_ignore_case(candidate.first, name)) { - if (header) { + if (use_index) { + const auto found{std::lower_bound(indexed.begin(), indexed.end(), + parameter.name, + [](const auto &entry, const auto name) { + return NameLess{}(entry.first, name); + })}; + if (found != indexed.end() && + equals_ignore_case(found->first, parameter.name)) { + header = found->second; + if (std::next(found) != indexed.end() && + equals_ignore_case(std::next(found)->first, parameter.name)) { return mcp_make_error_header_mismatch(version, &identifier, "Duplicate parameter header"); } - header = candidate.second; + } + } else { + for (const auto &candidate : headers) { + if (starts_with_ignore_case(candidate.first, PARAMETER_HEADER_PREFIX) && + equals_ignore_case( + candidate.first.substr(PARAMETER_HEADER_PREFIX.size()), + parameter.name)) { + if (header) { + return mcp_make_error_header_mismatch(version, &identifier, + "Duplicate parameter header"); + } + header = candidate.second; + } } } const auto *value{value_at(arguments, parameter)}; if (value == nullptr) { if (header) { - return mcp_make_error_header_mismatch(version, &identifier, name, - *header); + return mcp_make_error_header_mismatch( + version, &identifier, + std::string{PARAMETER_HEADER_PREFIX} + std::string{parameter.name}, + *header); } continue; } @@ -184,9 +248,10 @@ auto mcp_validate_parameter_headers( } } if (!matches) { - return mcp_make_error_header_mismatch(version, &identifier, name, - header.value_or(""), - parameter_text(*value)); + return mcp_make_error_header_mismatch( + version, &identifier, + std::string{PARAMETER_HEADER_PREFIX} + std::string{parameter.name}, + header.value_or(""), parameter_text(*value)); } } return std::nullopt; diff --git a/test/mcp/mcp_headers_test.cc b/test/mcp/mcp_headers_test.cc index 6c435e42d..4c63e9422 100644 --- a/test/mcp/mcp_headers_test.cc +++ b/test/mcp/mcp_headers_test.cc @@ -6,11 +6,12 @@ #include "mcp_helpers.h" -#include // std::array -#include // std::optional, std::nullopt -#include // std::string -#include // std::pair -#include // std::vector +#include // std::reverse +#include // std::array +#include // std::optional, std::nullopt +#include // std::string +#include // std::pair +#include // std::vector TEST(request_meta_validation_valid_minimum) { const auto envelope{sourcemeta::core::parse_json(R"JSON({ @@ -4041,3 +4042,115 @@ TEST(parameter_integer_rejects_a_header_that_is_not_a_number) { sourcemeta::core::JSON{0}, descriptors, arguments, views) .has_value()); } +namespace { +struct IndexedParameterHeaders { + std::vector parameters; + sourcemeta::core::JSON arguments{sourcemeta::core::JSON::make_object()}; + std::vector> owned; + std::vector> + headers; + + IndexedParameterHeaders() { + constexpr std::array NAMES{ + {"field0", "field1", "field2", "field3", "field4", "field5", "field6", + "field7", "field8"}}; + this->parameters.reserve(NAMES.size()); + for (const auto name : NAMES) { + this->parameters.push_back( + {.name = name, + .path = {name}, + .type = sourcemeta::core::JSON::Type::String}); + this->arguments.assign(name, sourcemeta::core::JSON{"plain"}); + } + this->mirror(); + } + + auto mirror() -> void { + this->headers.clear(); + this->owned = sourcemeta::core::mcp_make_parameter_headers(this->parameters, + this->arguments); + for (const auto &entry : this->owned) { + this->headers.emplace_back(entry.first, entry.second); + } + // Nine descriptors and eighteen headers select the indexed lookup path. + for (std::size_t index{0}; index < 9; ++index) { + this->headers.emplace_back("Other", "ignored"); + } + } + + [[nodiscard]] auto validate() const -> std::optional { + return sourcemeta::core::mcp_validate_parameter_headers( + sourcemeta::core::MCPProtocolVersion::V_2026_07_28, + sourcemeta::core::JSON{0}, this->parameters, this->arguments, + this->headers); + } +}; +} // namespace + +TEST(parameter_headers_index_accepts_mixed_case_names) { + IndexedParameterHeaders fixture; + fixture.headers.front().first = "mCp-pArAm-FiElD0"; + EXPECT_FALSE(fixture.validate().has_value()); +} + +TEST(parameter_headers_index_accepts_reversed_header_order) { + IndexedParameterHeaders fixture; + std::reverse(fixture.headers.begin(), fixture.headers.end()); + EXPECT_FALSE(fixture.validate().has_value()); +} + +TEST(parameter_headers_index_accepts_encoded_unicode) { + IndexedParameterHeaders fixture; + fixture.arguments.assign("field8", sourcemeta::core::JSON{" café "}); + fixture.mirror(); + EXPECT_FALSE(fixture.validate().has_value()); +} + +TEST(parameter_headers_index_ignores_duplicate_unused_names) { + IndexedParameterHeaders fixture; + fixture.headers.emplace_back("Mcp-Param-unused", "ignored"); + fixture.headers.emplace_back("mcp-param-UNUSED", "different"); + EXPECT_FALSE(fixture.validate().has_value()); +} + +TEST(parameter_headers_index_rejects_duplicate_matching_names) { + IndexedParameterHeaders fixture; + fixture.headers.emplace_back("mcp-param-FIELD0", "plain"); + EXPECT_EQ(fixture.validate().value().at("error").at("message").to_string(), + "Duplicate parameter header"); +} + +TEST(parameter_headers_index_rejects_wrong_argument_type) { + IndexedParameterHeaders fixture; + fixture.arguments.assign("field8", sourcemeta::core::JSON{true}); + EXPECT_EQ(fixture.validate().value().at("error").at("message").to_string(), + "Invalid mirrored argument"); +} + +TEST(parameter_headers_index_accepts_safe_integral_decimal) { + IndexedParameterHeaders fixture; + fixture.parameters.back().type = sourcemeta::core::JSON::Type::Integer; + fixture.arguments.assign("field8", + sourcemeta::core::parse_json("9007199254740991.0")); + fixture.mirror(); + EXPECT_FALSE(fixture.validate().has_value()); +} + +TEST(parameter_headers_index_rejects_unsafe_integer) { + IndexedParameterHeaders fixture; + fixture.parameters.back().type = sourcemeta::core::JSON::Type::Integer; + fixture.arguments.assign("field8", + sourcemeta::core::JSON{INT64_C(9007199254740992)}); + fixture.headers.at(8).second = "9007199254740992"; + EXPECT_EQ(fixture.validate().value().at("error").at("message").to_string(), + "Invalid mirrored argument"); +} + +TEST(parameter_headers_index_rejects_duplicate_descriptor_names) { + IndexedParameterHeaders fixture; + fixture.parameters.back().name = "FIELD0"; + EXPECT_MCP_REJECTS( + fixture.validate(), + "Parameter header names must be case-insensitively unique"); +}