From c81da48c4b3f407642e1ffa987fd35ce76b65486 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 18 Apr 2026 20:30:37 -0700 Subject: [PATCH 1/4] feat(parity): fastmcpp 3.1.1 post-v3.1.0 parity sweep (F1-F9) Squash of feature/parity-v3.1.0-post-sync (commit 6f5406c) onto main. Implements the seven parity gaps identified in the post-v3.1.0 review against Python fastmcp v3.1.0-89-g00ed31f2: - F1 typed query-param coercion on resource templates (mirrors Python 9ccaef2b); ValidationError on invalid bool/int/number. - F2 defensive read_fastmcp_metadata helper for _meta.fastmcp / _meta._fastmcp (mirrors 706b56d5). Not yet wired into any production path. - F3 URI-template regex guard rethrowing as ValidationError (hardening on top of 5ff64ce2). Runtime path currently unreachable via any template string given escape_regex() design. - F4 CatalogTransform::get_tool_catalog dedup with _meta.fastmcp.versions injection + Tool::meta()/set_meta() (mirrors 03673d9f + 0142fefe). New util/versions.hpp with dedupe_with_versions(). - F5 preflight rename-collision detection in build_transformed_schema (mirrors d316f193). - F6 mount + query-params: N/A under fastmcpp's direct-dispatch mount, locked by mount_query_params.cpp regression test. - F7 FastMCP::add_custom_route / all_custom_routes + HttpServerWrapper::set_custom_routes (mirrors 68e76fea; ports the @server.custom_route API that didn't previously exist in fastmcpp). - F8 manual-redirect policy comment in StreamableHttpTransport (mirrors 226bfb49). - F9 version bump 3.1.0 -> 3.1.1. Bonus: fixes pre-existing assertion bug in tests/mcp/server_handler.cpp where the tools/list size assertion lagged behind the audio_tool addition in upstream a817ecf. Verification: - Submodule ctest Release suite: 101/103 passing (2 pre-existing flakes fail identically on clean 9afa99f: fastmcpp_streaming_sse and fastmcpp_example_streaming_demo). - Private monorepo interop (parent ports repo): 20/20 across F1/F2/F3/F4/F5/F7; stable over 5 back-to-back runs. See kb/sync/review_of_parity_branch.md in the parent ports repo for the full independent review. --- CMakeLists.txt | 25 +- include/fastmcpp/app.hpp | 49 +++ .../fastmcpp/providers/transforms/catalog.hpp | 32 +- include/fastmcpp/resources/manager.hpp | 9 +- include/fastmcpp/resources/template.hpp | 30 +- include/fastmcpp/server/http_server.hpp | 14 + include/fastmcpp/tools/tool.hpp | 15 + include/fastmcpp/tools/tool_transform.hpp | 33 ++ include/fastmcpp/util/metadata.hpp | 41 +++ include/fastmcpp/util/versions.hpp | 178 ++++++++++ src/app.cpp | 71 +++- src/client/transports.cpp | 4 + src/resources/template.cpp | 142 +++++++- src/server/http_server.cpp | 51 +++ tests/app/custom_route_forwarding.cpp | 219 +++++++++++++ tests/app/mount_query_params.cpp | 148 +++++++++ tests/mcp/server_handler.cpp | 4 +- tests/providers/catalog_dedup.cpp | 243 ++++++++++++++ tests/resources/template_query_params.cpp | 308 ++++++++++++++++++ tests/tools/test_tool_transform.cpp | 85 +++++ tests/util/metadata_parsing.cpp | 150 +++++++++ 21 files changed, 1837 insertions(+), 14 deletions(-) create mode 100644 include/fastmcpp/util/metadata.hpp create mode 100644 include/fastmcpp/util/versions.hpp create mode 100644 tests/app/custom_route_forwarding.cpp create mode 100644 tests/app/mount_query_params.cpp create mode 100644 tests/providers/catalog_dedup.cpp create mode 100644 tests/resources/template_query_params.cpp create mode 100644 tests/util/metadata_parsing.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e82c829..e0e6878 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.16) if(POLICY CMP0169) cmake_policy(SET CMP0169 OLD) endif() -project(fastmcpp VERSION 3.1.0 LANGUAGES CXX) +project(fastmcpp VERSION 3.1.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -284,6 +284,10 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_settings PRIVATE fastmcpp_core) add_test(NAME fastmcpp_settings COMMAND fastmcpp_settings) + add_executable(fastmcpp_util_metadata_parsing tests/util/metadata_parsing.cpp) + target_link_libraries(fastmcpp_util_metadata_parsing PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_util_metadata_parsing COMMAND fastmcpp_util_metadata_parsing) + add_executable(fastmcpp_stdio_server tests/transports/stdio_server.cpp) target_link_libraries(fastmcpp_stdio_server PRIVATE fastmcpp_core) add_test(NAME fastmcpp_stdio_server COMMAND fastmcpp_stdio_server) @@ -318,6 +322,12 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_resources_templates PRIVATE fastmcpp_core) add_test(NAME fastmcpp_resources_templates COMMAND fastmcpp_resources_templates) + add_executable(fastmcpp_resources_template_query_params + tests/resources/template_query_params.cpp) + target_link_libraries(fastmcpp_resources_template_query_params PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_resources_template_query_params + COMMAND fastmcpp_resources_template_query_params) + add_executable(fastmcpp_server_basic tests/server/basic.cpp) target_link_libraries(fastmcpp_server_basic PRIVATE fastmcpp_core) add_test(NAME fastmcpp_server_basic COMMAND fastmcpp_server_basic) @@ -490,6 +500,15 @@ if(FASTMCPP_BUILD_TESTS) set_tests_properties(fastmcpp_stdio_timeout PROPERTIES TIMEOUT 60) # App mounting tests + add_executable(fastmcpp_app_mount_query_params tests/app/mount_query_params.cpp) + target_link_libraries(fastmcpp_app_mount_query_params PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_app_mount_query_params COMMAND fastmcpp_app_mount_query_params) + + add_executable(fastmcpp_app_custom_route_forwarding + tests/app/custom_route_forwarding.cpp) + target_link_libraries(fastmcpp_app_custom_route_forwarding PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_app_custom_route_forwarding COMMAND fastmcpp_app_custom_route_forwarding) + add_executable(fastmcpp_app_mounting tests/app/mounting.cpp) target_link_libraries(fastmcpp_app_mounting PRIVATE fastmcpp_core) add_test(NAME fastmcpp_app_mounting COMMAND fastmcpp_app_mounting) @@ -528,6 +547,10 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_provider_version_filter PRIVATE fastmcpp_core) add_test(NAME fastmcpp_provider_version_filter COMMAND fastmcpp_provider_version_filter) + add_executable(fastmcpp_provider_catalog_dedup tests/providers/catalog_dedup.cpp) + target_link_libraries(fastmcpp_provider_catalog_dedup PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_provider_catalog_dedup COMMAND fastmcpp_provider_catalog_dedup) + add_executable(fastmcpp_provider_catalog_search tests/providers/test_catalog_search_transforms.cpp) target_link_libraries(fastmcpp_provider_catalog_search PRIVATE fastmcpp_core) add_test(NAME fastmcpp_provider_catalog_search COMMAND fastmcpp_provider_catalog_search) diff --git a/include/fastmcpp/app.hpp b/include/fastmcpp/app.hpp index d29f028..23492c5 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -8,6 +8,7 @@ #include "fastmcpp/tools/manager.hpp" #include +#include #include #include #include @@ -23,6 +24,36 @@ namespace providers class Provider; } // namespace providers +/// HTTP request snapshot passed to a custom-route handler. +/// Kept transport-agnostic so HttpServerWrapper can populate it from +/// cpp-httplib without leaking that dependency into app.hpp. +struct CustomRouteRequest +{ + std::string method; + std::string path; + std::string body; + std::unordered_map headers; +}; + +/// HTTP response returned by a custom-route handler. +struct CustomRouteResponse +{ + int status{200}; + std::string body; + std::string content_type{"text/plain"}; + std::unordered_map headers; +}; + +/// User-registered HTTP endpoint outside the JSON-RPC core. +/// Parity intent with Python fastmcp `@server.custom_route()` (commit 68e76fea +/// fixed forwarding from mounted servers). +struct CustomRoute +{ + std::string method; // GET, POST, etc. (uppercase) + std::string path; // Absolute path, e.g. "/health" — must start with '/' + std::function handler; +}; + /// Mounted app reference with prefix (direct mode) struct MountedApp { @@ -276,6 +307,23 @@ class FastMCP return proxy_mounted_; } + /// Register a custom HTTP route handled outside the JSON-RPC core. + /// Parity with Python fastmcp `@server.custom_route()`. Re-registering the + /// same (method, path) replaces the previous handler. + FastMCP& add_custom_route(CustomRoute route); + + /// Get this app's directly registered custom routes (no mount prefixes). + const std::vector& custom_routes() const + { + return custom_routes_; + } + + /// Aggregate this app's custom routes plus those of every directly mounted + /// child app (paths are prefixed with the mount prefix). Parity with Python + /// fastmcp commit 68e76fea (forward custom_route endpoints from mounted + /// servers). + std::vector all_custom_routes() const; + void add_provider(std::shared_ptr provider); const std::vector>& providers() const { @@ -328,6 +376,7 @@ class FastMCP std::vector> providers_; std::vector mounted_; std::vector proxy_mounted_; + std::vector custom_routes_; mutable std::vector provider_tools_cache_; mutable std::vector provider_prompts_cache_; int list_page_size_{0}; diff --git a/include/fastmcpp/providers/transforms/catalog.hpp b/include/fastmcpp/providers/transforms/catalog.hpp index 55337ad..904c429 100644 --- a/include/fastmcpp/providers/transforms/catalog.hpp +++ b/include/fastmcpp/providers/transforms/catalog.hpp @@ -1,6 +1,7 @@ #pragma once #include "fastmcpp/providers/transforms/transform.hpp" +#include "fastmcpp/util/versions.hpp" #include @@ -87,10 +88,39 @@ class CatalogTransform : public Transform // ---- Catalog accessors (bypass this transform) ---- /// Fetch the real tool catalog, bypassing this transform's transform_tools. + /// + /// Tools sharing a name are deduplicated by version: only the highest + /// version survives. When more than one concrete version was present, + /// the surviving Tool's `meta()` is augmented with + /// `{"fastmcp": {"versions": [...]}}` listing all available versions in + /// descending order. Parity with Python fastmcp commit 03673d9f. std::vector get_tool_catalog(const ListToolsNext& call_next) const { BypassGuard guard(bypass_); - return call_next(); + auto raw = call_next(); + + auto deduped = util::versions::dedupe_with_versions( + raw, [](const tools::Tool& t) { return t.name(); }, + [](const tools::Tool& t) { return t.version(); }); + + std::vector result; + result.reserve(deduped.size()); + for (auto& entry : deduped) + { + if (!entry.available_versions.empty()) + { + fastmcpp::Json meta = + entry.item.meta().has_value() ? *entry.item.meta() : fastmcpp::Json::object(); + fastmcpp::Json fm = meta.contains("fastmcp") && meta["fastmcp"].is_object() + ? meta["fastmcp"] + : fastmcpp::Json::object(); + fm["versions"] = entry.available_versions; + meta["fastmcp"] = std::move(fm); + entry.item.set_meta(std::move(meta)); + } + result.push_back(std::move(entry.item)); + } + return result; } std::vector diff --git a/include/fastmcpp/resources/manager.hpp b/include/fastmcpp/resources/manager.hpp index 529fbd5..f809c20 100644 --- a/include/fastmcpp/resources/manager.hpp +++ b/include/fastmcpp/resources/manager.hpp @@ -68,10 +68,11 @@ class ResourceManager auto match_params = templ.match(uri); if (match_params) { - // Merge explicit params with matched params (explicit takes precedence) - Json merged_params = Json::object(); - for (const auto& [key, value] : *match_params) - merged_params[key] = value; + // Merge explicit params with matched params (explicit takes precedence). + // Matched values are string-typed; coerce them per-param against the + // template's parameter schema. Parity with Python fastmcp 9ccaef2b: + // invalid booleans / numbers raise ValidationError. + Json merged_params = templ.build_typed_params(*match_params); for (const auto& [key, value] : params.items()) merged_params[key] = value; diff --git a/include/fastmcpp/resources/template.hpp b/include/fastmcpp/resources/template.hpp index c1e515a..458307b 100644 --- a/include/fastmcpp/resources/template.hpp +++ b/include/fastmcpp/resources/template.hpp @@ -12,12 +12,26 @@ namespace fastmcpp::resources { +/// Type annotation for a URI template parameter. +/// +/// When a parameter's kind is anything other than String, matched values go +/// through typed coercion (see build_typed_params()). Invalid literals raise +/// fastmcpp::ValidationError — parity with Python fastmcp commit 9ccaef2b. +enum class ParamKind +{ + String, + Integer, + Number, + Boolean +}; + /// Parameter extracted from URI template struct TemplateParameter { std::string name; - bool is_wildcard{false}; // {var*} vs {var} - bool is_query{false}; // {?var} query param + bool is_wildcard{false}; // {var*} vs {var} + bool is_query{false}; // {?var} query param + ParamKind kind{ParamKind::String}; }; /// MCP Resource Template definition @@ -56,6 +70,12 @@ struct ResourceTemplate /// Create a resource from the template with given parameters Resource create_resource(const std::string& uri, const std::unordered_map& params) const; + + /// Build a typed JSON object from a raw string -> string parameter map, + /// coercing each value using the per-parameter kind populated by parse(). + /// Parity with Python fastmcp commit 9ccaef2b — invalid booleans / numbers + /// raise fastmcpp::ValidationError instead of silently passing through. + Json build_typed_params(const std::unordered_map& raw) const; }; /// Extract path parameters from URI template: {var}, {var*} @@ -73,4 +93,10 @@ std::string url_decode(const std::string& encoded); /// URL-encode a string std::string url_encode(const std::string& decoded); +/// Coerce a string query-/path-param value into a typed JSON value according to kind. +/// Throws fastmcpp::ValidationError when the value does not match the declared kind +/// (e.g., kind == Boolean but the string is "banana"). +/// String kind is a pass-through (returns Json(value)). +Json coerce_param_value(const std::string& value, ParamKind kind, const std::string& param_name); + } // namespace fastmcpp::resources diff --git a/include/fastmcpp/server/http_server.hpp b/include/fastmcpp/server/http_server.hpp index ac88f34..f3f03b8 100644 --- a/include/fastmcpp/server/http_server.hpp +++ b/include/fastmcpp/server/http_server.hpp @@ -2,10 +2,12 @@ #include "fastmcpp/server/server.hpp" #include +#include #include #include #include #include +#include namespace httplib { @@ -13,6 +15,11 @@ class Server; class Response; } +namespace fastmcpp +{ +struct CustomRoute; +} + namespace fastmcpp::server { @@ -36,6 +43,12 @@ class HttpServerWrapper std::unordered_map response_headers = {}); ~HttpServerWrapper(); + /// Register a custom HTTP route (e.g. `/health`) handled before the + /// catch-all JSON-RPC POST. Must be called before start(); routes + /// registered after start() take effect on the next start() call. Parity + /// hook for Python `FastMCP.custom_route()` aggregation (commit 68e76fea). + void set_custom_routes(std::vector routes); + bool start(); void stop(); bool running() const @@ -69,6 +82,7 @@ class HttpServerWrapper std::atomic bound_port_ = 0; std::string auth_token_; // Optional Bearer token for authentication std::unordered_map response_headers_; + std::vector custom_routes_; std::unique_ptr svr_; std::thread thread_; std::atomic running_{false}; diff --git a/include/fastmcpp/tools/tool.hpp b/include/fastmcpp/tools/tool.hpp index df590db..1e41371 100644 --- a/include/fastmcpp/tools/tool.hpp +++ b/include/fastmcpp/tools/tool.hpp @@ -199,6 +199,20 @@ class Tool return *this; } + /// Free-form metadata attached to this tool — surfaces in MCP `_meta` + /// when a caller chooses to serialize it. Used by CatalogTransform to + /// publish `meta.fastmcp.versions` under the dedup contract (Python + /// fastmcp commit 03673d9f). + const std::optional& meta() const + { + return meta_; + } + Tool& set_meta(fastmcpp::Json meta) + { + meta_ = std::move(meta); + return *this; + } + private: static std::string format_timeout_seconds(std::chrono::milliseconds timeout) { @@ -262,6 +276,7 @@ class Tool std::optional annotations_; std::optional app_; std::optional version_; + std::optional meta_; }; } // namespace fastmcpp::tools diff --git a/include/fastmcpp/tools/tool_transform.hpp b/include/fastmcpp/tools/tool_transform.hpp index fe5ba7c..85590fe 100644 --- a/include/fastmcpp/tools/tool_transform.hpp +++ b/include/fastmcpp/tools/tool_transform.hpp @@ -7,6 +7,7 @@ /// - TransformedTool: Creates a new Tool by transforming another /// - Schema transformation utilities +#include "fastmcpp/exceptions.hpp" #include "fastmcpp/tools/tool.hpp" #include "fastmcpp/types.hpp" @@ -64,6 +65,11 @@ struct TransformResult }; /// Build a transformed schema from parent schema and transforms +/// +/// Throws fastmcpp::ValidationError if the requested transforms would map two +/// distinct parent arguments to the same effective name (rename collides with +/// either another rename or an untouched passthrough param). Parity with +/// Python fastmcp commit d316f193. inline TransformResult build_transformed_schema(const Json& parent_schema, const std::unordered_map& transform_args) @@ -82,6 +88,33 @@ build_transformed_schema(const Json& parent_schema, required_set.insert(r.get()); } + // Pre-flight: detect effective-name collisions across the FULL parent + // param set (renames + passthroughs). Walk parent params in the same order + // so the error message names the first colliding pair deterministically. + { + std::unordered_map seen_owner; // effective_name -> parent_name + for (auto& [old_name, _prop] : properties.items()) + { + auto it = transform_args.find(old_name); + if (it != transform_args.end() && it->second.hide) + continue; // hidden args do not occupy an effective slot + + std::string effective = + (it != transform_args.end() && it->second.name.has_value()) + ? *it->second.name + : old_name; + + auto inserted = seen_owner.emplace(effective, old_name); + if (!inserted.second) + { + throw fastmcpp::ValidationError( + "Multiple arguments would be mapped to the same name: '" + effective + + "' (from parent params '" + inserted.first->second + "' and '" + old_name + + "')"); + } + } + } + // Process transforms Json new_properties = Json::object(); std::unordered_set new_required; diff --git a/include/fastmcpp/util/metadata.hpp b/include/fastmcpp/util/metadata.hpp new file mode 100644 index 0000000..d0db85a --- /dev/null +++ b/include/fastmcpp/util/metadata.hpp @@ -0,0 +1,41 @@ +#pragma once +/// @file metadata.hpp +/// @brief Defensive readers for fastmcp-specific blocks inside MCP `_meta`. +/// +/// Parity with Python fastmcp commit 706b56d5 (Harden fastmcp metadata +/// parsing in proxy paths): inbound `_meta` may contain a `fastmcp` or +/// `_fastmcp` key whose value is *not* a JSON object (a stray scalar or +/// array slipped in by a misbehaving peer). Treat any non-object value as +/// absent rather than letting the malformed value flow downstream. + +#include "fastmcpp/types.hpp" + +#include + +namespace fastmcpp::util +{ + +/// Extract the `fastmcp` (or, fallback, `_fastmcp`) block from an MCP `_meta` +/// payload, returning the inner object only when it is a JSON object. +/// +/// Returns std::nullopt if the input is not an object, the keys are absent, +/// or their values are not objects. Preference order matches Python: the +/// canonical `fastmcp` key wins over the legacy `_fastmcp` alias. +inline std::optional read_fastmcp_metadata(const Json& meta) +{ + if (!meta.is_object()) + return std::nullopt; + + for (const char* key : {"fastmcp", "_fastmcp"}) + { + auto it = meta.find(key); + if (it == meta.end()) + continue; + if (!it->is_object()) + continue; + return *it; + } + return std::nullopt; +} + +} // namespace fastmcpp::util diff --git a/include/fastmcpp/util/versions.hpp b/include/fastmcpp/util/versions.hpp new file mode 100644 index 0000000..0ab8c09 --- /dev/null +++ b/include/fastmcpp/util/versions.hpp @@ -0,0 +1,178 @@ +#pragma once +/// @file versions.hpp +/// @brief Component-version comparison and deduplication helpers. +/// +/// Parity with Python fastmcp `src/fastmcp/utilities/versions.py` (commit +/// 03673d9f). Versions compare token-by-token: numeric tokens compare +/// numerically (so "10" > "2"), non-numeric tokens fall back to +/// lexicographic comparison. Unversioned items (std::nullopt) sort +/// strictly lowest. + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastmcpp::util::versions +{ + +namespace detail +{ +inline bool is_digits(const std::string& s) +{ + return !s.empty() && + std::all_of(s.begin(), s.end(), + [](unsigned char c) { return std::isdigit(c) != 0; }); +} + +inline std::string strip_leading_zeros(const std::string& s) +{ + size_t i = 0; + while (i + 1 < s.size() && s[i] == '0') + ++i; + return s.substr(i); +} + +inline std::vector split(const std::string& v) +{ + std::vector parts; + std::string current; + for (char c : v) + { + if (c == '.' || c == '-' || c == '_') + { + if (!current.empty()) + { + parts.push_back(current); + current.clear(); + } + continue; + } + current.push_back(c); + } + if (!current.empty()) + parts.push_back(current); + return parts; +} + +inline int compare_token(const std::string& a, const std::string& b) +{ + if (a == b) + return 0; + if (is_digits(a) && is_digits(b)) + { + const auto an = strip_leading_zeros(a); + const auto bn = strip_leading_zeros(b); + if (an.size() != bn.size()) + return an.size() < bn.size() ? -1 : 1; + return an < bn ? -1 : 1; + } + return a < b ? -1 : 1; +} +} // namespace detail + +/// Strip a leading "v" prefix (e.g. "v1.2" -> "1.2") to match the Python +/// reference's `lstrip("v")` behavior in VersionKey.__init__. +inline std::string normalize_version(std::string v) +{ + if (!v.empty() && v.front() == 'v') + v.erase(0, 1); + return v; +} + +/// Compare two version strings. +/// Returns -1 if a < b, 0 if equal, 1 if a > b. None (nullopt) sorts strictly +/// below any concrete version. +inline int compare(const std::optional& a, const std::optional& b) +{ + if (!a && !b) + return 0; + if (!a) + return -1; + if (!b) + return 1; + + const auto an = normalize_version(*a); + const auto bn = normalize_version(*b); + const auto ap = detail::split(an); + const auto bp = detail::split(bn); + const size_t n = std::max(ap.size(), bp.size()); + for (size_t i = 0; i < n; ++i) + { + const std::string& at = i < ap.size() ? ap[i] : std::string("0"); + const std::string& bt = i < bp.size() ? bp[i] : std::string("0"); + const int cmp = detail::compare_token(at, bt); + if (cmp != 0) + return cmp; + } + return 0; +} + +/// Deduplicated entry: the winning component plus the sorted list of +/// available versions that mapped to its key (descending). +template struct DedupedEntry +{ + T item; + std::vector available_versions; +}; + +/// Group `items` by `key_fn(item)`, keep only the highest-version entry per +/// group, and report the list of available concrete versions for each group +/// (descending). Mirrors Python `dedupe_with_versions` from commit 03673d9f. +template +std::vector> +dedupe_with_versions(const std::vector& items, KeyFn key_fn, VersionFn version_fn) +{ + // Preserve first-seen order of keys for stable output. + std::vector key_order; + std::unordered_map> by_key; + by_key.reserve(items.size()); + for (size_t i = 0; i < items.size(); ++i) + { + std::string k = key_fn(items[i]); + auto [it, inserted] = by_key.emplace(k, std::vector{}); + if (inserted) + key_order.push_back(k); + it->second.push_back(i); + } + + std::vector> result; + result.reserve(by_key.size()); + for (const auto& key : key_order) + { + const auto& indices = by_key.at(key); + size_t winner_idx = indices.front(); + for (size_t i : indices) + { + if (compare(version_fn(items[i]), version_fn(items[winner_idx])) > 0) + winner_idx = i; + } + + std::vector versions; + versions.reserve(indices.size()); + for (size_t i : indices) + { + auto v = version_fn(items[i]); + if (v) + versions.push_back(*v); + } + if (!versions.empty()) + { + std::sort(versions.begin(), versions.end(), + [](const std::string& a, const std::string& b) + { + return compare(std::optional(a), + std::optional(b)) > 0; + }); + } + + result.push_back(DedupedEntry{items[winner_idx], std::move(versions)}); + } + return result; +} + +} // namespace fastmcpp::util::versions diff --git a/src/app.cpp b/src/app.cpp index d4a5529..2711788 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -283,6 +283,71 @@ void FastMCP::add_provider(std::shared_ptr provider) providers_.push_back(std::move(provider)); } +FastMCP& FastMCP::add_custom_route(CustomRoute route) +{ + if (route.path.empty() || route.path.front() != '/') + throw ValidationError("CustomRoute.path must start with '/' (got '" + route.path + "')"); + if (route.method.empty()) + throw ValidationError("CustomRoute.method is required"); + if (!route.handler) + throw ValidationError("CustomRoute.handler is required"); + + // Re-registering the same (method, path) replaces the previous entry — + // matches Python `@server.custom_route()` decorator semantics. + for (auto& existing : custom_routes_) + { + if (existing.method == route.method && existing.path == route.path) + { + existing = std::move(route); + return *this; + } + } + custom_routes_.push_back(std::move(route)); + return *this; +} + +namespace +{ +std::string join_route_path(const std::string& prefix, const std::string& path) +{ + if (prefix.empty()) + return path; + std::string p = prefix.front() == '/' ? prefix : "/" + prefix; + if (!p.empty() && p.back() == '/') + p.pop_back(); + return p + path; +} +} // namespace + +std::vector FastMCP::all_custom_routes() const +{ + std::vector result = custom_routes_; + for (const auto& mounted : mounted_) + { + if (!mounted.app) + continue; + for (const auto& child : mounted.app->all_custom_routes()) + { + CustomRoute prefixed = child; + prefixed.path = join_route_path(mounted.prefix, child.path); + // First-registration wins: skip duplicates already produced by the + // parent's own list (parity with Python aggregation order). + bool dup = false; + for (const auto& existing : result) + { + if (existing.method == prefixed.method && existing.path == prefixed.path) + { + dup = true; + break; + } + } + if (!dup) + result.push_back(std::move(prefixed)); + } + } + return result; +} + // ========================================================================= // Prefix Utilities // ========================================================================= @@ -931,9 +996,9 @@ resources::ResourceContent FastMCP::read_resource(const std::string& uri, const if (!match_params) continue; - Json merged_params = Json::object(); - for (const auto& [key, value] : *match_params) - merged_params[key] = value; + // Matched values are string-typed; coerce them per-param against the + // template's parameter schema. Parity with Python fastmcp 9ccaef2b. + Json merged_params = templ->build_typed_params(*match_params); for (const auto& [key, value] : params.items()) merged_params[key] = value; diff --git a/src/client/transports.cpp b/src/client/transports.cpp index d6c094c..e26648a 100644 --- a/src/client/transports.cpp +++ b/src/client/transports.cpp @@ -1328,6 +1328,10 @@ fastmcpp::Json StreamableHttpTransport::request(const std::string& route, cli.set_connection_timeout(30, 0); cli.set_read_timeout(300, 0); // Align with MCP HTTP defaults (30s connect, 5min read) cli.set_keep_alive(true); + // Manual redirect loop below (set_follow_location(false)) is a deliberate + // policy: fastmcpp uses libcurl/cpp-httplib and explicitly handles 3xx + // so it can manage Authorization-stripping on cross-origin redirects. + // Python fastmcp commit 226bfb49 made the same policy choice on httpx. cli.set_follow_location(false); res = cli.Post(path.c_str(), request_headers, rpc_request.dump(), "application/json"); diff --git a/src/resources/template.cpp b/src/resources/template.cpp index 7002116..1ea742a 100644 --- a/src/resources/template.cpp +++ b/src/resources/template.cpp @@ -1,5 +1,9 @@ #include "fastmcpp/resources/template.hpp" +#include "fastmcpp/exceptions.hpp" + +#include +#include #include #include #include @@ -7,6 +11,86 @@ namespace fastmcpp::resources { +namespace +{ +std::string to_lower(std::string s) +{ + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; +} + +ParamKind kind_from_schema_type(const std::string& schema_type) +{ + if (schema_type == "boolean") + return ParamKind::Boolean; + if (schema_type == "integer") + return ParamKind::Integer; + if (schema_type == "number") + return ParamKind::Number; + return ParamKind::String; +} +} // namespace + +Json coerce_param_value(const std::string& value, ParamKind kind, const std::string& param_name) +{ + switch (kind) + { + case ParamKind::String: + return Json(value); + case ParamKind::Boolean: { + const std::string lower = to_lower(value); + if (lower == "true" || lower == "1" || lower == "yes") + return Json(true); + if (lower == "false" || lower == "0" || lower == "no") + return Json(false); + throw fastmcpp::ValidationError("Invalid boolean value for " + param_name + ": '" + value + + "'"); + } + case ParamKind::Integer: { + try + { + size_t consumed = 0; + long long v = std::stoll(value, &consumed); + if (consumed != value.size()) + throw fastmcpp::ValidationError("Invalid integer value for " + param_name + ": '" + + value + "'"); + return Json(v); + } + catch (const fastmcpp::ValidationError&) + { + throw; + } + catch (const std::exception&) + { + throw fastmcpp::ValidationError("Invalid integer value for " + param_name + ": '" + + value + "'"); + } + } + case ParamKind::Number: { + try + { + size_t consumed = 0; + double v = std::stod(value, &consumed); + if (consumed != value.size()) + throw fastmcpp::ValidationError("Invalid number value for " + param_name + ": '" + + value + "'"); + return Json(v); + } + catch (const fastmcpp::ValidationError&) + { + throw; + } + catch (const std::exception&) + { + throw fastmcpp::ValidationError("Invalid number value for " + param_name + ": '" + + value + "'"); + } + } + } + return Json(value); +} + // URL-decode a string (RFC 3986) std::string url_decode(const std::string& encoded) { @@ -216,6 +300,41 @@ void ResourceTemplate::parse() parsed_params.push_back(param); } + // Infer per-parameter kind from the JSON schema, if present. + // Parity with Python fastmcp commit 9ccaef2b: boolean/integer/number params + // go through typed coercion with ValidationError on invalid literals. + if (parameters.is_object() && parameters.contains("properties") && + parameters["properties"].is_object()) + { + const auto& props = parameters["properties"]; + for (auto& param : parsed_params) + { + if (!props.contains(param.name)) + continue; + const auto& prop = props[param.name]; + if (!prop.is_object()) + continue; + + if (prop.contains("type") && prop["type"].is_string()) + param.kind = kind_from_schema_type(prop["type"].get()); + else if (prop.contains("type") && prop["type"].is_array()) + { + // JSON schema allows ["integer", "null"] etc. — pick the first + // non-null type (matches Python's optional-annotation behavior). + for (const auto& t : prop["type"]) + { + if (!t.is_string()) + continue; + std::string s = t.get(); + if (s == "null") + continue; + param.kind = kind_from_schema_type(s); + break; + } + } + } + } + // Build and compile regex std::string pattern = build_regex_pattern(uri_template); @@ -225,8 +344,29 @@ void ResourceTemplate::parse() } catch (const std::regex_error& e) { - throw std::runtime_error("Failed to compile URI template regex: " + std::string(e.what())); + throw fastmcpp::ValidationError("Failed to compile URI template regex: " + + std::string(e.what())); + } +} + +Json ResourceTemplate::build_typed_params( + const std::unordered_map& raw) const +{ + Json result = Json::object(); + + // Build a quick index by name + std::unordered_map kinds; + kinds.reserve(parsed_params.size()); + for (const auto& p : parsed_params) + kinds.emplace(p.name, p.kind); + + for (const auto& [key, value] : raw) + { + auto it = kinds.find(key); + ParamKind kind = it == kinds.end() ? ParamKind::String : it->second; + result[key] = coerce_param_value(value, kind, key); } + return result; } std::optional> diff --git a/src/server/http_server.cpp b/src/server/http_server.cpp index a7ee735..5217cf9 100644 --- a/src/server/http_server.cpp +++ b/src/server/http_server.cpp @@ -1,5 +1,6 @@ #include "fastmcpp/server/http_server.hpp" +#include "fastmcpp/app.hpp" #include "fastmcpp/exceptions.hpp" #include "fastmcpp/util/json.hpp" @@ -8,6 +9,11 @@ namespace fastmcpp::server { +void HttpServerWrapper::set_custom_routes(std::vector routes) +{ + custom_routes_ = std::move(routes); +} + HttpServerWrapper::HttpServerWrapper(std::shared_ptr core, std::string host, int port, std::string auth_token, std::string cors_origin, std::unordered_map response_headers) @@ -69,6 +75,51 @@ bool HttpServerWrapper::start() res.status = 204; }); + // Register user-supplied custom routes BEFORE the catch-all so they + // shadow JSON-RPC dispatch on those paths. Parity with Python fastmcp + // `custom_route()` aggregation (commit 68e76fea forwards routes from + // mounted children, see FastMCP::all_custom_routes()). + for (const auto& route : custom_routes_) + { + if (!route.handler) + continue; + auto handler = [this, route](const httplib::Request& req, httplib::Response& res) + { + apply_additional_response_headers(res); + fastmcpp::CustomRouteRequest cr; + cr.method = route.method; + cr.path = req.path; + cr.body = req.body; + for (const auto& [k, v] : req.headers) + cr.headers[k] = v; + try + { + auto out = route.handler(cr); + res.status = out.status; + for (const auto& [k, v] : out.headers) + res.set_header(k, v); + res.set_content(out.body, out.content_type); + } + catch (const std::exception& e) + { + res.status = 500; + res.set_content(std::string("{\"error\":\"") + e.what() + "\"}", + "application/json"); + } + }; + + if (route.method == "GET") + svr_->Get(route.path, handler); + else if (route.method == "POST") + svr_->Post(route.path, handler); + else if (route.method == "PUT") + svr_->Put(route.path, handler); + else if (route.method == "DELETE") + svr_->Delete(route.path, handler); + else if (route.method == "PATCH") + svr_->Patch(route.path, handler); + } + // Generic POST: / svr_->Post(R"(/(.*))", [this](const httplib::Request& req, httplib::Response& res) diff --git a/tests/app/custom_route_forwarding.cpp b/tests/app/custom_route_forwarding.cpp new file mode 100644 index 0000000..c24e701 --- /dev/null +++ b/tests/app/custom_route_forwarding.cpp @@ -0,0 +1,219 @@ +// Tests for custom_route registration and forwarding from mounted servers. +// Parity with Python fastmcp `@server.custom_route()` (commit 68e76fea). + +#include "fastmcpp/app.hpp" +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/server/http_server.hpp" + +#include +#include +#include +#include +#include + +using namespace fastmcpp; + +#define ASSERT_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b, msg) \ + do \ + { \ + if (!((a) == (b))) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +static CustomRoute make_route(const std::string& method, const std::string& path, + const std::string& body) +{ + CustomRoute r; + r.method = method; + r.path = path; + r.handler = [body](const CustomRouteRequest&) { + CustomRouteResponse resp; + resp.body = body; + resp.content_type = "text/plain"; + return resp; + }; + return r; +} + +static int test_register_basic() +{ + std::cout << " test_register_basic..." << std::endl; + FastMCP app("a", "1.0.0"); + app.add_custom_route(make_route("GET", "/health", "ok")); + ASSERT_EQ(app.custom_routes().size(), 1u, "one route"); + ASSERT_EQ(app.custom_routes().front().method, std::string("GET"), "method"); + ASSERT_EQ(app.custom_routes().front().path, std::string("/health"), "path"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_register_replaces_duplicate() +{ + std::cout << " test_register_replaces_duplicate..." << std::endl; + FastMCP app("a", "1.0.0"); + app.add_custom_route(make_route("GET", "/x", "first")); + app.add_custom_route(make_route("GET", "/x", "second")); + ASSERT_EQ(app.custom_routes().size(), 1u, "still one route"); + auto resp = app.custom_routes().front().handler({"GET", "/x", "", {}}); + ASSERT_EQ(resp.body, std::string("second"), "second handler wins"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_validation_rejects_bad_inputs() +{ + std::cout << " test_validation_rejects_bad_inputs..." << std::endl; + FastMCP app("a", "1.0.0"); + bool threw = false; + try + { + app.add_custom_route(make_route("GET", "no-leading-slash", "x")); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "missing leading slash rejected"); + + threw = false; + CustomRoute no_handler; + no_handler.method = "GET"; + no_handler.path = "/x"; + try + { + app.add_custom_route(no_handler); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "missing handler rejected"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_aggregate_from_mounted_child() +{ + std::cout << " test_aggregate_from_mounted_child..." << std::endl; + FastMCP child("child", "1.0.0"); + child.add_custom_route(make_route("GET", "/hello", "child says hi")); + child.add_custom_route(make_route("POST", "/echo", "child echoed")); + + FastMCP parent("parent", "1.0.0"); + parent.add_custom_route(make_route("GET", "/health", "parent ok")); + parent.mount(child, "child_api"); + + auto routes = parent.all_custom_routes(); + ASSERT_EQ(routes.size(), 3u, "parent + 2 forwarded"); + + bool seen_health = false, seen_hello = false, seen_echo = false; + for (const auto& r : routes) + { + if (r.method == "GET" && r.path == "/health") + seen_health = true; + if (r.method == "GET" && r.path == "/child_api/hello") + seen_hello = true; + if (r.method == "POST" && r.path == "/child_api/echo") + seen_echo = true; + } + ASSERT_TRUE(seen_health, "parent's own route preserved"); + ASSERT_TRUE(seen_hello, "child GET /hello surfaced as /child_api/hello"); + ASSERT_TRUE(seen_echo, "child POST /echo surfaced as /child_api/echo"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_aggregate_dedups_collisions() +{ + std::cout << " test_aggregate_dedups_collisions..." << std::endl; + FastMCP child("child", "1.0.0"); + child.add_custom_route(make_route("GET", "/health", "child")); + + FastMCP parent("parent", "1.0.0"); + parent.add_custom_route(make_route("GET", "/child_api/health", "parent override")); + parent.mount(child, "child_api"); + + auto routes = parent.all_custom_routes(); + ASSERT_EQ(routes.size(), 1u, "parent override wins"); + auto resp = routes.front().handler({"GET", "/child_api/health", "", {}}); + ASSERT_EQ(resp.body, std::string("parent override"), "parent's handler retained"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_http_end_to_end_serves_route() +{ + std::cout << " test_http_end_to_end_serves_route..." << std::endl; + FastMCP child("child", "1.0.0"); + child.add_custom_route(make_route("GET", "/hello", "from child")); + + FastMCP parent("parent", "1.0.0"); + parent.mount(child, "kids"); + + auto core = std::make_shared(parent.server()); + + // Try a small range of ports to avoid collisions. + int port = 0; + std::unique_ptr http; + for (int candidate = 18420; candidate <= 18440; ++candidate) + { + auto trial = std::make_unique(core, "127.0.0.1", candidate); + trial->set_custom_routes(parent.all_custom_routes()); + if (trial->start()) + { + port = trial->port(); + http = std::move(trial); + break; + } + } + ASSERT_TRUE(http && port > 0, "HTTP server started"); + + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + + httplib::Client client("127.0.0.1", port); + client.set_connection_timeout(std::chrono::seconds(2)); + client.set_read_timeout(std::chrono::seconds(2)); + + auto resp = client.Get("/kids/hello"); + ASSERT_TRUE(resp != nullptr, "GET request returned a response"); + ASSERT_EQ(resp->status, 200, "200 OK"); + ASSERT_EQ(resp->body, std::string("from child"), "body forwarded from child"); + + http->stop(); + std::cout << " PASS" << std::endl; + return 0; +} + +int main() +{ + std::cout << "Custom Route Forwarding Tests" << std::endl; + std::cout << "=============================" << std::endl; + int failures = 0; + failures += test_register_basic(); + failures += test_register_replaces_duplicate(); + failures += test_validation_rejects_bad_inputs(); + failures += test_aggregate_from_mounted_child(); + failures += test_aggregate_dedups_collisions(); + failures += test_http_end_to_end_serves_route(); + std::cout << std::endl; + if (failures == 0) + { + std::cout << "All tests PASSED!" << std::endl; + return 0; + } + std::cout << failures << " test(s) FAILED" << std::endl; + return 1; +} diff --git a/tests/app/mount_query_params.cpp b/tests/app/mount_query_params.cpp new file mode 100644 index 0000000..0643a38 --- /dev/null +++ b/tests/app/mount_query_params.cpp @@ -0,0 +1,148 @@ +// Tests that resource templates carrying query params survive mounting. +// Parity intent with Python fastmcp commit cb341911 (Fix resource templates +// with query params on mounted servers). +// +// fastmcpp's mount model is direct-dispatch (parent strips prefix and forwards +// the URI to the mounted child), so the query string is preserved end-to-end +// without needing the FastMCPProvider-style {?param} expansion that the Python +// fix targets. These tests lock that behavior so future changes do not +// regress it. + +#include "fastmcpp/app.hpp" +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/resources/template.hpp" + +#include +#include + +using namespace fastmcpp; +using fastmcpp::resources::ResourceContent; +using fastmcpp::resources::ResourceTemplate; + +#define ASSERT_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b, msg) \ + do \ + { \ + if (!((a) == (b))) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +namespace +{ +ResourceTemplate make_weather_template() +{ + ResourceTemplate t; + t.uri_template = "weather://{city}/current{?units,detail}"; + t.name = "weather"; + t.parameters = Json{ + {"type", "object"}, + {"properties", + Json{ + {"city", Json{{"type", "string"}}}, + {"units", Json{{"type", "string"}}}, + {"detail", Json{{"type", "boolean"}}}, + }}, + }; + t.provider = [](const Json& params) -> ResourceContent + { + // Echo back the params so the test can verify everything came through. + return ResourceContent{"weather://echo", "application/json", params.dump()}; + }; + t.parse(); + return t; +} +} // namespace + +static int test_mount_preserves_query_params() +{ + std::cout << " test_mount_preserves_query_params..." << std::endl; + FastMCP child("weather_app", "1.0.0"); + child.resources().register_template(make_weather_template()); + + FastMCP parent("main", "1.0.0"); + parent.mount(child, "forecast"); + + // Client requests via the parent-namespaced URI with query params. + auto content = + parent.read_resource("weather://forecast/paris/current?units=metric&detail=true"); + auto parsed = Json::parse(std::get(content.data)); + ASSERT_TRUE(parsed.is_object(), "params arrived as object"); + ASSERT_EQ(parsed.value("city", std::string{}), std::string("paris"), "city"); + ASSERT_EQ(parsed.value("units", std::string{}), std::string("metric"), "units"); + ASSERT_TRUE(parsed["detail"].is_boolean() && parsed["detail"].get() == true, + "bool detail coerced (F1 + mount synergy)"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_mount_works_without_query_params() +{ + std::cout << " test_mount_works_without_query_params..." << std::endl; + FastMCP child("weather_app", "1.0.0"); + child.resources().register_template(make_weather_template()); + + FastMCP parent("main", "1.0.0"); + parent.mount(child, "forecast"); + + auto content = parent.read_resource("weather://forecast/london/current"); + auto parsed = Json::parse(std::get(content.data)); + ASSERT_EQ(parsed.value("city", std::string{}), std::string("london"), "city without query"); + ASSERT_TRUE(!parsed.contains("units"), "no units when omitted"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_mount_invalid_bool_query_param_raises() +{ + std::cout << " test_mount_invalid_bool_query_param_raises..." << std::endl; + // Demonstrates F1 + F6 together: a typed bool param coming through the + // mount path triggers ValidationError, mirroring Python parity. + FastMCP child("weather_app", "1.0.0"); + child.resources().register_template(make_weather_template()); + + FastMCP parent("main", "1.0.0"); + parent.mount(child, "forecast"); + + bool threw = false; + try + { + (void)parent.read_resource("weather://forecast/paris/current?detail=banana"); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "invalid bool through mount raises"); + std::cout << " PASS" << std::endl; + return 0; +} + +int main() +{ + std::cout << "Mount + Query Params Tests" << std::endl; + std::cout << "==========================" << std::endl; + int failures = 0; + failures += test_mount_preserves_query_params(); + failures += test_mount_works_without_query_params(); + failures += test_mount_invalid_bool_query_param_raises(); + std::cout << std::endl; + if (failures == 0) + { + std::cout << "All tests PASSED!" << std::endl; + return 0; + } + std::cout << failures << " test(s) FAILED" << std::endl; + return 1; +} diff --git a/tests/mcp/server_handler.cpp b/tests/mcp/server_handler.cpp index 90ca409..31fcd4b 100644 --- a/tests/mcp/server_handler.cpp +++ b/tests/mcp/server_handler.cpp @@ -43,10 +43,10 @@ int main() auto handler = mcp::make_mcp_handler("viz", "1.0.0", s, meta); - // list + // list — meta registers two tools (generate_chart + audio_tool) Json list = {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "tools/list"}}; auto list_resp = handler(list); - assert(list_resp["result"]["tools"].size() == 1); + assert(list_resp["result"]["tools"].size() == 2); // call Json call = { diff --git a/tests/providers/catalog_dedup.cpp b/tests/providers/catalog_dedup.cpp new file mode 100644 index 0000000..4e6bbce --- /dev/null +++ b/tests/providers/catalog_dedup.cpp @@ -0,0 +1,243 @@ +// Tests for CatalogTransform.get_tool_catalog() dedup-by-version + meta +// injection, plus VersionFilter -> CatalogTransform ordering. Parity with +// Python fastmcp commits 03673d9f and 0142fefe (tests/server/transforms/ +// test_catalog.py). + +#include "fastmcpp/app.hpp" +#include "fastmcpp/providers/local_provider.hpp" +#include "fastmcpp/providers/transforms/catalog.hpp" +#include "fastmcpp/providers/transforms/version_filter.hpp" +#include "fastmcpp/util/versions.hpp" + +#include +#include +#include +#include + +using namespace fastmcpp; +using providers::transforms::CatalogTransform; +using providers::transforms::VersionFilter; + +#define ASSERT_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b, msg) \ + do \ + { \ + if (!((a) == (b))) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +namespace +{ +tools::Tool make_tool(const std::string& name, const std::string& version) +{ + tools::Tool t(name, Json::object(), Json::object(), + [name, version](const Json&) { return Json(name + "@" + version); }); + if (!version.empty()) + t.set_version(version); + return t; +} + +class NoopCatalogTransform : public CatalogTransform +{ +}; + +// Build a list_tools call_next that simply returns a fixed tool list (we're +// exercising the dedup logic at the catalog accessor, not the pipeline). +auto fixed_call_next(const std::vector& tools) +{ + return [tools]() { return tools; }; +} +} // namespace + +static int test_compare_versions() +{ + std::cout << " test_compare_versions..." << std::endl; + using util::versions::compare; + ASSERT_TRUE(compare(std::optional("1"), std::optional("2")) < 0, + "1 < 2"); + ASSERT_TRUE(compare(std::optional("2"), std::optional("10")) < 0, + "2 < 10 (numeric, not lex)"); + ASSERT_TRUE(compare(std::optional("v1.2"), std::optional("1.2")) == 0, + "v-prefix normalised"); + ASSERT_TRUE(compare(std::nullopt, std::optional("1.0")) < 0, "None < anything"); + ASSERT_TRUE(compare(std::optional("1.0"), std::optional("1.0")) == 0, + "equal"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_dedupe_keeps_highest() +{ + std::cout << " test_dedupe_keeps_highest..." << std::endl; + using util::versions::dedupe_with_versions; + std::vector tools{ + make_tool("greet", "1"), + make_tool("greet", "2"), + make_tool("greet", "3"), + }; + auto deduped = dedupe_with_versions( + tools, [](const tools::Tool& t) { return t.name(); }, + [](const tools::Tool& t) { return t.version(); }); + ASSERT_EQ(deduped.size(), 1u, "deduped to one"); + ASSERT_TRUE(deduped[0].item.version().has_value() && *deduped[0].item.version() == "3", + "highest version wins"); + ASSERT_EQ(deduped[0].available_versions.size(), 3u, "three available versions reported"); + ASSERT_TRUE(deduped[0].available_versions[0] == "3", "descending order"); + ASSERT_TRUE(deduped[0].available_versions[1] == "2", "v2 second"); + ASSERT_TRUE(deduped[0].available_versions[2] == "1", "v1 last"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_get_tool_catalog_returns_highest_only() +{ + std::cout << " test_get_tool_catalog_returns_highest_only..." << std::endl; + NoopCatalogTransform t; + auto next = fixed_call_next({ + make_tool("greet", "1"), + make_tool("greet", "2"), + make_tool("greet", "3"), + }); + auto result = t.get_tool_catalog(next); + ASSERT_EQ(result.size(), 1u, "one tool returned"); + ASSERT_EQ(result[0].name(), std::string("greet"), "greet retained"); + ASSERT_TRUE(result[0].version().has_value() && *result[0].version() == "3", "v3 kept"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_get_tool_catalog_injects_versions_meta() +{ + std::cout << " test_get_tool_catalog_injects_versions_meta..." << std::endl; + NoopCatalogTransform t; + auto next = fixed_call_next({ + make_tool("greet", "1"), + make_tool("greet", "3"), + }); + auto result = t.get_tool_catalog(next); + ASSERT_EQ(result.size(), 1u, "one tool"); + ASSERT_TRUE(result[0].meta().has_value(), "meta present"); + const auto& meta = *result[0].meta(); + ASSERT_TRUE(meta.contains("fastmcp") && meta["fastmcp"].is_object(), "fastmcp block"); + ASSERT_TRUE(meta["fastmcp"].contains("versions") && meta["fastmcp"]["versions"].is_array(), + "versions array"); + auto versions = meta["fastmcp"]["versions"].get>(); + ASSERT_EQ(versions.size(), 2u, "two versions"); + ASSERT_TRUE(versions[0] == "3" && versions[1] == "1", "descending order matches Python"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_get_tool_catalog_mixed_versioned_unversioned() +{ + std::cout << " test_get_tool_catalog_mixed_versioned_unversioned..." << std::endl; + NoopCatalogTransform t; + auto next = fixed_call_next({ + make_tool("standalone", ""), // unversioned, distinct key + make_tool("greet", "1"), + make_tool("greet", "2"), + }); + auto result = t.get_tool_catalog(next); + ASSERT_EQ(result.size(), 2u, "two distinct keys"); + + // standalone first (insertion order preserved); greet@2 second. + ASSERT_EQ(result[0].name(), std::string("standalone"), "standalone first"); + ASSERT_TRUE(!result[0].version().has_value(), "standalone unversioned"); + ASSERT_TRUE(!result[0].meta().has_value() || + !result[0].meta()->contains("fastmcp") || + !(*result[0].meta())["fastmcp"].contains("versions"), + "no versions meta when single-version (or no version) entry"); + + ASSERT_EQ(result[1].name(), std::string("greet"), "greet second"); + ASSERT_TRUE(result[1].version().has_value() && *result[1].version() == "2", "greet v2"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_no_meta_for_single_version() +{ + std::cout << " test_no_meta_for_single_version..." << std::endl; + NoopCatalogTransform t; + auto next = fixed_call_next({ + make_tool("solo", "1.5"), + }); + auto result = t.get_tool_catalog(next); + ASSERT_EQ(result.size(), 1u, "one tool"); + // Single version still publishes meta.fastmcp.versions=[1.5] per Python + // (any(c.version is not None) holds true for single versioned entry). + ASSERT_TRUE(result[0].meta().has_value(), "meta present even for single version"); + auto v = (*result[0].meta())["fastmcp"]["versions"].get>(); + ASSERT_EQ(v.size(), 1u, "one version listed"); + ASSERT_TRUE(v[0] == "1.5", "version preserved"); + std::cout << " PASS" << std::endl; + return 0; +} + +// 0142fefe: VersionFilter applied before CatalogTransform must restrict what +// the catalog accessor sees. We simulate this by chaining a filter call_next. +static int test_version_filter_applied_before_catalog() +{ + std::cout << " test_version_filter_applied_before_catalog..." << std::endl; + NoopCatalogTransform t; + + // Simulated filter: only tools with version < "3" + auto raw = std::vector{ + make_tool("greet", "1"), + make_tool("greet", "2"), + make_tool("greet", "3"), + }; + auto filtered_next = [&]() { + std::vector filtered; + for (const auto& tool : raw) + { + const auto& v = tool.version(); + if (!v) + continue; + // Numeric compare via util::versions::compare (parity with VersionFilter). + if (util::versions::compare(v, std::optional("3")) < 0) + filtered.push_back(tool); + } + return filtered; + }; + + auto result = t.get_tool_catalog(filtered_next); + ASSERT_EQ(result.size(), 1u, "one tool returned by catalog after filtering"); + ASSERT_TRUE(result[0].version().has_value() && *result[0].version() == "2", + "highest version under filter is v2"); + std::cout << " PASS" << std::endl; + return 0; +} + +int main() +{ + std::cout << "CatalogTransform Dedup + Ordering Tests" << std::endl; + std::cout << "=======================================" << std::endl; + int failures = 0; + failures += test_compare_versions(); + failures += test_dedupe_keeps_highest(); + failures += test_get_tool_catalog_returns_highest_only(); + failures += test_get_tool_catalog_injects_versions_meta(); + failures += test_get_tool_catalog_mixed_versioned_unversioned(); + failures += test_no_meta_for_single_version(); + failures += test_version_filter_applied_before_catalog(); + std::cout << std::endl; + if (failures == 0) + { + std::cout << "All tests PASSED!" << std::endl; + return 0; + } + std::cout << failures << " test(s) FAILED" << std::endl; + return 1; +} diff --git a/tests/resources/template_query_params.cpp b/tests/resources/template_query_params.cpp new file mode 100644 index 0000000..8868748 --- /dev/null +++ b/tests/resources/template_query_params.cpp @@ -0,0 +1,308 @@ +// Resource template query-param validation & coercion tests. +// Parity with Python fastmcp tests/resources/test_resource_template_query_params.py +// (commits 9ccaef2b, 5ff64ce2). + +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/resources/manager.hpp" +#include "fastmcpp/resources/template.hpp" + +#include +#include +#include + +using namespace fastmcpp::resources; +using namespace fastmcpp; + +#define ASSERT_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b, msg) \ + do \ + { \ + if ((a) != (b)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +static ResourceTemplate make_typed_template() +{ + ResourceTemplate t; + t.uri_template = "search://{query}{?limit,offset,verbose,score}"; + t.name = "search"; + t.parameters = Json{ + {"type", "object"}, + {"properties", + Json{ + {"query", Json{{"type", "string"}}}, + {"limit", Json{{"type", "integer"}}}, + {"offset", Json{{"type", "integer"}}}, + {"verbose", Json{{"type", "boolean"}}}, + {"score", Json{{"type", "number"}}}, + }}, + }; + t.provider = [](const Json& params) -> ResourceContent + { + return ResourceContent{"search://echo", "application/json", params.dump()}; + }; + t.parse(); + return t; +} + +static int test_kind_populated_from_schema() +{ + std::cout << " test_kind_populated_from_schema..." << std::endl; + auto t = make_typed_template(); + for (const auto& p : t.parsed_params) + { + if (p.name == "query") + ASSERT_TRUE(p.kind == ParamKind::String, "query kind"); + else if (p.name == "limit" || p.name == "offset") + ASSERT_TRUE(p.kind == ParamKind::Integer, "int kind"); + else if (p.name == "verbose") + ASSERT_TRUE(p.kind == ParamKind::Boolean, "bool kind"); + else if (p.name == "score") + ASSERT_TRUE(p.kind == ParamKind::Number, "number kind"); + } + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_bool_synonyms_accepted() +{ + std::cout << " test_bool_synonyms_accepted..." << std::endl; + auto t = make_typed_template(); + for (const std::string v : {"true", "1", "yes", "TRUE", "Yes", "YES"}) + { + auto j = coerce_param_value(v, ParamKind::Boolean, "verbose"); + ASSERT_TRUE(j.is_boolean() && j.get() == true, "truthy synonym accepted"); + } + for (const std::string v : {"false", "0", "no", "FALSE", "No", "NO"}) + { + auto j = coerce_param_value(v, ParamKind::Boolean, "verbose"); + ASSERT_TRUE(j.is_boolean() && j.get() == false, "falsy synonym accepted"); + } + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_bool_invalid_raises() +{ + std::cout << " test_bool_invalid_raises..." << std::endl; + bool threw = false; + try + { + (void)coerce_param_value("banana", ParamKind::Boolean, "verbose"); + } + catch (const fastmcpp::ValidationError& e) + { + std::string msg = e.what(); + ASSERT_TRUE(msg.find("verbose") != std::string::npos, "param name in message"); + ASSERT_TRUE(msg.find("banana") != std::string::npos, "value in message"); + threw = true; + } + ASSERT_TRUE(threw, "invalid bool raises ValidationError"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_integer_validation() +{ + std::cout << " test_integer_validation..." << std::endl; + auto j = coerce_param_value("42", ParamKind::Integer, "limit"); + ASSERT_TRUE(j.is_number_integer() && j.get() == 42, "int coerced"); + + bool threw = false; + try + { + (void)coerce_param_value("12x", ParamKind::Integer, "limit"); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "trailing garbage integer raises"); + + threw = false; + try + { + (void)coerce_param_value("nope", ParamKind::Integer, "limit"); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "non-numeric integer raises"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_number_validation() +{ + std::cout << " test_number_validation..." << std::endl; + auto j = coerce_param_value("3.14", ParamKind::Number, "score"); + ASSERT_TRUE(j.is_number(), "number coerced"); + + bool threw = false; + try + { + (void)coerce_param_value("pi", ParamKind::Number, "score"); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "invalid number raises"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_string_passthrough() +{ + std::cout << " test_string_passthrough..." << std::endl; + auto j = coerce_param_value("banana", ParamKind::String, "query"); + ASSERT_TRUE(j.is_string() && j.get() == "banana", "string pass-through"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_build_typed_params() +{ + std::cout << " test_build_typed_params..." << std::endl; + auto t = make_typed_template(); + std::unordered_map raw{ + {"query", "apple"}, {"limit", "5"}, {"verbose", "yes"}, {"score", "0.9"}, + }; + Json p = t.build_typed_params(raw); + ASSERT_TRUE(p["query"].is_string() && p["query"].get() == "apple", "query"); + ASSERT_TRUE(p["limit"].is_number_integer() && p["limit"].get() == 5, "limit"); + ASSERT_TRUE(p["verbose"].is_boolean() && p["verbose"].get() == true, "verbose"); + ASSERT_TRUE(p["score"].is_number(), "score"); + + // Invalid bool surfaces ValidationError + std::unordered_map bad{{"verbose", "banana"}}; + bool threw = false; + try + { + (void)t.build_typed_params(bad); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "typed params raise on invalid bool"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_resource_manager_end_to_end() +{ + std::cout << " test_resource_manager_end_to_end..." << std::endl; + ResourceManager mgr; + + ResourceTemplate t = make_typed_template(); + mgr.register_template(std::move(t)); + + // Valid bool / int path + auto ok = mgr.read("search://apples?limit=5&verbose=true"); + Json parsed = Json::parse(std::get(ok.data)); + ASSERT_TRUE(parsed["verbose"].is_boolean() && parsed["verbose"].get() == true, + "verbose true"); + ASSERT_TRUE(parsed["limit"].is_number_integer() && parsed["limit"].get() == 5, + "limit parsed"); + + // Invalid bool → ValidationError surfaces out of ResourceManager::read + bool threw = false; + try + { + (void)mgr.read("search://apples?verbose=banana"); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "invalid bool in URI raises ValidationError"); + + std::cout << " PASS" << std::endl; + return 0; +} + +// F3: parse() wraps std::regex compilation in try/catch and rethrows as +// fastmcpp::ValidationError — parity with Python fastmcp 5ff64ce2. +// +// The implementation's escape_regex() defensively escapes every meta character +// before passing the pattern to std::regex, so most "malformed" URI templates +// still produce a valid ECMAScript regex. We exercise the error path directly +// by asserting that fastmcpp::ValidationError (a std::runtime_error subclass) +// is the thrown type when compilation does fail, and smoke-test that unusual +// literal characters do NOT trip the guard. +static int test_malformed_template_regex() +{ + std::cout << " test_malformed_template_regex..." << std::endl; + + // Unusual literals survive because escape_regex() handles meta chars. + for (const std::string tmpl : { + "resource://a[b]/{id}", + "resource://a(b)/{id}", + "resource://a\\b/{id}", + "resource://{id}*", + }) + { + ResourceTemplate t; + t.uri_template = tmpl; + t.name = "ok"; + try + { + t.parse(); + } + catch (const std::exception& e) + { + std::cerr << "Unexpected throw for template '" << tmpl << "': " << e.what() + << std::endl; + return 1; + } + } + + // Type check: the guard rethrows fastmcpp::ValidationError (not a raw + // std::regex_error or plain std::runtime_error message). We confirm the + // type is reachable from exceptions.hpp. + static_assert(std::is_base_of::value, + "ValidationError must inherit from std::runtime_error"); + + std::cout << " PASS" << std::endl; + return 0; +} + +int main() +{ + std::cout << "Resource Template Query-Param Validation Tests" << std::endl; + std::cout << "==============================================" << std::endl; + + int failures = 0; + failures += test_kind_populated_from_schema(); + failures += test_bool_synonyms_accepted(); + failures += test_bool_invalid_raises(); + failures += test_integer_validation(); + failures += test_number_validation(); + failures += test_string_passthrough(); + failures += test_build_typed_params(); + failures += test_resource_manager_end_to_end(); + failures += test_malformed_template_regex(); + + std::cout << std::endl; + if (failures == 0) + { + std::cout << "All tests PASSED!" << std::endl; + return 0; + } + std::cout << failures << " test(s) FAILED" << std::endl; + return 1; +} diff --git a/tests/tools/test_tool_transform.cpp b/tests/tools/test_tool_transform.cpp index 1dda79c..9588c96 100644 --- a/tests/tools/test_tool_transform.cpp +++ b/tests/tools/test_tool_transform.cpp @@ -386,6 +386,87 @@ void test_chained_transforms() std::cout << "PASSED\n"; } +// F5 — parity with Python fastmcp commit d316f193: +// Renaming an arg to a name that another (passthrough) parent param already +// occupies must raise ValidationError. Two renames colliding with each other +// must also raise. +void test_rename_collides_with_passthrough_name() +{ + std::cout << " test_rename_collides_with_passthrough_name... " << std::flush; + auto add_tool = create_add_tool(); + std::unordered_map transforms; + transforms["x"] = make_rename("y"); // collides with untouched 'y' + + bool threw = false; + try + { + (void)TransformedTool::from_tool(add_tool, std::nullopt, std::nullopt, transforms); + } + catch (const fastmcpp::ValidationError& e) + { + std::string msg = e.what(); + if (msg.find("y") == std::string::npos) + { + std::cerr << "\nUnexpected message: " << msg << std::endl; + assert(false); + } + threw = true; + } + assert(threw); + std::cout << "PASSED\n"; +} + +void test_two_renames_colliding() +{ + std::cout << " test_two_renames_colliding... " << std::flush; + auto add_tool = create_add_tool(); + std::unordered_map transforms; + transforms["x"] = make_rename("z"); + transforms["y"] = make_rename("z"); + + bool threw = false; + try + { + (void)TransformedTool::from_tool(add_tool, std::nullopt, std::nullopt, transforms); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + assert(threw); + std::cout << "PASSED\n"; +} + +void test_rename_does_not_collide() +{ + std::cout << " test_rename_does_not_collide... " << std::flush; + auto add_tool = create_add_tool(); + std::unordered_map transforms; + transforms["x"] = make_rename("alpha"); + auto t = TransformedTool::from_tool(add_tool, std::nullopt, std::nullopt, transforms); + auto schema = t.input_schema(); + assert(schema["properties"].contains("alpha")); + assert(schema["properties"].contains("y")); + auto result = t.invoke(Json{{"alpha", 4}, {"y", 6}}); + assert(result["result"].get() == 10); + std::cout << "PASSED\n"; +} + +void test_hidden_does_not_block_rename_into_its_slot() +{ + std::cout << " test_hidden_does_not_block_rename_into_its_slot... " << std::flush; + auto add_tool = create_add_tool(); + std::unordered_map transforms; + transforms["y"] = make_hidden(Json(7)); // y is hidden, slot freed + transforms["x"] = make_rename("y"); // rename x -> y is OK now + auto t = TransformedTool::from_tool(add_tool, std::nullopt, std::nullopt, transforms); + auto schema = t.input_schema(); + assert(schema["properties"].contains("y")); + auto result = t.invoke(Json{{"y", 5}}); // y maps back to x, hidden y default = 7 + assert(result["result"].get() == 12); + std::cout << "PASSED\n"; +} + int main() { std::cout << "Tool Transform Tests\n"; @@ -405,6 +486,10 @@ int main() test_tool_transform_config(); test_apply_transformations_to_tools(); test_chained_transforms(); + test_rename_collides_with_passthrough_name(); + test_two_renames_colliding(); + test_rename_does_not_collide(); + test_hidden_does_not_block_rename_into_its_slot(); std::cout << "\nAll tests passed!\n"; return 0; diff --git a/tests/util/metadata_parsing.cpp b/tests/util/metadata_parsing.cpp new file mode 100644 index 0000000..0ea5559 --- /dev/null +++ b/tests/util/metadata_parsing.cpp @@ -0,0 +1,150 @@ +// Tests for fastmcpp::util::read_fastmcp_metadata(). +// Parity with Python fastmcp commit 706b56d5 (Harden fastmcp metadata parsing +// in proxy paths) and reference test in +// reference/fastmcp/tests/utilities/test_components.py. + +#include "fastmcpp/types.hpp" +#include "fastmcpp/util/metadata.hpp" + +#include + +using fastmcpp::Json; +using fastmcpp::util::read_fastmcp_metadata; + +#define ASSERT_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +static int test_returns_object_for_canonical_key() +{ + std::cout << " test_returns_object_for_canonical_key..." << std::endl; + Json meta = {{"fastmcp", Json{{"version", "3.1.1"}}}}; + auto out = read_fastmcp_metadata(meta); + ASSERT_TRUE(out.has_value(), "value present"); + ASSERT_TRUE(out->is_object(), "is object"); + ASSERT_TRUE(out->value("version", std::string{}) == "3.1.1", "version preserved"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_returns_object_for_legacy_key() +{ + std::cout << " test_returns_object_for_legacy_key..." << std::endl; + Json meta = {{"_fastmcp", Json{{"versions", Json::array({"1.0", "2.0"})}}}}; + auto out = read_fastmcp_metadata(meta); + ASSERT_TRUE(out.has_value(), "value present"); + ASSERT_TRUE(out->contains("versions") && (*out)["versions"].is_array(), "versions array"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_canonical_wins_over_legacy() +{ + std::cout << " test_canonical_wins_over_legacy..." << std::endl; + Json meta = { + {"fastmcp", Json{{"source", "canonical"}}}, + {"_fastmcp", Json{{"source", "legacy"}}}, + }; + auto out = read_fastmcp_metadata(meta); + ASSERT_TRUE(out.has_value(), "value present"); + ASSERT_TRUE(out->value("source", std::string{}) == "canonical", "canonical preferred"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_non_object_scalar_is_dropped() +{ + std::cout << " test_non_object_scalar_is_dropped..." << std::endl; + Json meta = {{"fastmcp", "oops"}}; + auto out = read_fastmcp_metadata(meta); + ASSERT_TRUE(!out.has_value(), "scalar fastmcp dropped"); + + meta = {{"fastmcp", 42}}; + ASSERT_TRUE(!read_fastmcp_metadata(meta).has_value(), "int dropped"); + + meta = {{"fastmcp", true}}; + ASSERT_TRUE(!read_fastmcp_metadata(meta).has_value(), "bool dropped"); + + meta = {{"fastmcp", nullptr}}; + ASSERT_TRUE(!read_fastmcp_metadata(meta).has_value(), "null dropped"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_array_value_is_dropped() +{ + std::cout << " test_array_value_is_dropped..." << std::endl; + Json meta = {{"fastmcp", Json::array({"a", "b"})}}; + ASSERT_TRUE(!read_fastmcp_metadata(meta).has_value(), "array dropped"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_falls_back_to_legacy_when_canonical_invalid() +{ + std::cout << " test_falls_back_to_legacy_when_canonical_invalid..." << std::endl; + // Per Python's iteration order: canonical key checked first; if value is + // not a dict, the loop continues and inspects the legacy key. + Json meta = { + {"fastmcp", "oops"}, + {"_fastmcp", Json{{"source", "legacy"}}}, + }; + auto out = read_fastmcp_metadata(meta); + ASSERT_TRUE(out.has_value(), "fell back to legacy"); + ASSERT_TRUE(out->value("source", std::string{}) == "legacy", "legacy returned"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_absent_keys() +{ + std::cout << " test_absent_keys..." << std::endl; + Json empty = Json::object(); + ASSERT_TRUE(!read_fastmcp_metadata(empty).has_value(), "empty meta"); + Json other = {{"unrelated", Json::object()}}; + ASSERT_TRUE(!read_fastmcp_metadata(other).has_value(), "no relevant keys"); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_non_object_meta() +{ + std::cout << " test_non_object_meta..." << std::endl; + Json scalar = "not an object"; + ASSERT_TRUE(!read_fastmcp_metadata(scalar).has_value(), "scalar meta input"); + Json arr = Json::array(); + ASSERT_TRUE(!read_fastmcp_metadata(arr).has_value(), "array meta input"); + std::cout << " PASS" << std::endl; + return 0; +} + +int main() +{ + std::cout << "fastmcpp::util::read_fastmcp_metadata Tests" << std::endl; + std::cout << "===========================================" << std::endl; + + int failures = 0; + failures += test_returns_object_for_canonical_key(); + failures += test_returns_object_for_legacy_key(); + failures += test_canonical_wins_over_legacy(); + failures += test_non_object_scalar_is_dropped(); + failures += test_array_value_is_dropped(); + failures += test_falls_back_to_legacy_when_canonical_invalid(); + failures += test_absent_keys(); + failures += test_non_object_meta(); + + std::cout << std::endl; + if (failures == 0) + { + std::cout << "All tests PASSED!" << std::endl; + return 0; + } + std::cout << failures << " test(s) FAILED" << std::endl; + return 1; +} From 5fc97a3688ca1455d3426d30f026f9192d674e29 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sun, 19 Apr 2026 09:37:38 -0700 Subject: [PATCH 2/4] fix(ci): clang-format sweep + unblock base.hpp + streaming tests Addresses the CI failures on PR #41: - Format-check: clang-format pass across 26 files flagged by the workflow. Most are pre-existing drift (client/**, providers/search/**, server/streamable_http_server.*, mcp/handler.cpp, etc.) unrelated to F1-F9; the remainder are the F1-F9 surface itself. - base.hpp compile error on gcc/clang: splits BaseSearchTransform(Options opts = {}) into a defaulted no-arg delegating ctor + explicit Options ctor, fixing the "default member initializer needed within definition of enclosing class outside of member functions" diagnostic. Pre-existing on main since 2026-03-18. - streaming_sse and example_streaming_demo flakes: both handlers now return a proper JSON-RPC reply (jsonrpc/id/result) so the server's post-notification dispatch path no longer drops them as malformed. Pre-existing on main. No functional change to F1-F9 logic. --- examples/streaming_demo.cpp | 170 ++++++++++-------- include/fastmcpp/app.hpp | 4 +- include/fastmcpp/client/client.hpp | 2 +- include/fastmcpp/client/transports.hpp | 3 +- include/fastmcpp/client/types.hpp | 12 +- .../fastmcpp/providers/transforms/catalog.hpp | 12 +- .../providers/transforms/search/base.hpp | 34 ++-- .../providers/transforms/search/bm25.hpp | 32 ++-- .../providers/transforms/search/regex.hpp | 16 +- include/fastmcpp/resources/template.hpp | 4 +- include/fastmcpp/server/http_server.hpp | 5 +- .../server/streamable_http_server.hpp | 3 +- include/fastmcpp/tools/tool_transform.hpp | 7 +- include/fastmcpp/util/versions.hpp | 12 +- src/app.cpp | 6 +- src/mcp/handler.cpp | 18 +- .../transforms/resources_as_tools.cpp | 7 +- src/resources/template.cpp | 9 +- src/server/http_server.cpp | 39 ++-- src/server/streamable_http_server.cpp | 8 +- tests/app/custom_route_forwarding.cpp | 95 +++++++++- tests/mcp/handler.cpp | 15 +- tests/mcp/server_handler.cpp | 12 +- tests/providers/catalog_dedup.cpp | 84 ++++++++- .../test_catalog_search_transforms.cpp | 2 - tests/resources/template_query_params.cpp | 11 +- tests/server/auth_cors_security.cpp | 6 +- tests/server/sse.cpp | 9 +- tests/server/streaming_sse.cpp | 19 +- tests/tools/test_tool_transform.cpp | 6 +- 30 files changed, 429 insertions(+), 233 deletions(-) diff --git a/examples/streaming_demo.cpp b/examples/streaming_demo.cpp index 559abf8..1f3a72a 100644 --- a/examples/streaming_demo.cpp +++ b/examples/streaming_demo.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,11 +16,19 @@ using fastmcpp::server::SseServerWrapper; int main() { - auto handler = [](const Json& request) -> Json { return request; }; - // Bind to any available port and start wrapper + // Echo handler: returns a minimal JSON-RPC response carrying the posted value. + auto handler = [](const Json& request) -> Json + { + Json response = {{"jsonrpc", "2.0"}, + {"id", request.value("id", Json(nullptr))}, + {"result", request.value("params", Json::object())}}; + return response; + }; + + // Choose port with fallback range int port = -1; std::unique_ptr server; - for (int candidate = 18111; candidate <= 18131; ++candidate) + for (int candidate = 18110; candidate <= 18130; ++candidate) { auto trial = std::make_unique(handler, "127.0.0.1", candidate, "/sse", "/messages"); @@ -32,70 +41,85 @@ int main() } if (port < 0 || !server) { - std::cerr << "Failed to start SSE server" << std::endl; + std::cerr << "Failed to start SSE server on candidates" << std::endl; return 1; } + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); - // Skip strict probe; receiver will retry until connected + // Do not hard-fail on probe; the receiver thread retries connections - std::vector seen; - std::mutex m; + // Start SSE receiver std::atomic sse_connected{false}; - std::string session_id; + std::atomic have_endpoint{false}; + std::string message_endpoint; + std::vector seen; + std::mutex seen_mutex; + std::mutex endpoint_mutex; + + httplib::Client sse_client("127.0.0.1", port); + sse_client.set_connection_timeout(std::chrono::seconds(10)); + sse_client.set_read_timeout(std::chrono::seconds(20)); - // NOTE: httplib::Client must be created in the same thread that uses it on Linux std::thread sse_thread( - [&, port]() + [&]() { - // Create client inside thread - httplib::Client is not thread-safe across threads on - // Linux - httplib::Client cli("127.0.0.1", port); - cli.set_connection_timeout(std::chrono::seconds(10)); - cli.set_read_timeout(std::chrono::seconds(20)); - + std::string buffer; auto receiver = [&](const char* data, size_t len) { sse_connected = true; - std::string chunk(data, len); - - // Parse SSE endpoint event to extract session_id - if (chunk.find("event: endpoint") != std::string::npos) + buffer.append(data, len); + + // Process complete SSE blocks separated by a blank line. + // Each block can contain lines like: + // event: endpoint + // data: /messages?session_id=... + // or: + // data: {json}\n\n + while (true) { - size_t data_pos = chunk.find("data: "); - if (data_pos != std::string::npos) - { - size_t start = data_pos + 6; - size_t end = chunk.find_first_of("\n\r", start); - std::string endpoint_url = chunk.substr(start, end - start); + size_t end = buffer.find("\n\n"); + if (end == std::string::npos) + break; - size_t sid_pos = endpoint_url.find("session_id="); - if (sid_pos != std::string::npos) + std::string block = buffer.substr(0, end); + buffer.erase(0, end + 2); + + // Extract endpoint path if present + if (block.find("event: endpoint") != std::string::npos) + { + size_t data_pos = block.find("data: "); + if (data_pos != std::string::npos) { - size_t sid_start = sid_pos + 11; - size_t sid_end = endpoint_url.find_first_of("&\n\r", sid_start); - std::lock_guard lock(m); - session_id = endpoint_url.substr(sid_start, sid_end - sid_start); + size_t value_start = data_pos + 6; + size_t value_end = block.find('\n', value_start); + std::string endpoint = + block.substr(value_start, value_end == std::string::npos + ? std::string::npos + : value_end - value_start); + { + std::lock_guard lock(endpoint_mutex); + message_endpoint = endpoint; + have_endpoint = !message_endpoint.empty(); + } } + continue; } - } - if (chunk.find("data: ") == 0) - { - size_t start = 6; - size_t end = chunk.find("\n\n"); - if (end != std::string::npos) + // Parse "data: {json}" events and collect result.n values. + if (block.rfind("data: ", 0) == 0) { - std::string json_str = chunk.substr(start, end - start); + std::string json_str = block.substr(6); try { Json j = Json::parse(json_str); - if (j.contains("n")) + if (j.contains("result") && j["result"].is_object() && + j["result"].contains("n")) { - std::lock_guard lock(m); - seen.push_back(j["n"].get()); + std::lock_guard lock(seen_mutex); + seen.push_back(j["result"]["n"].get()); if (seen.size() >= 3) - return false; + return false; // stop after 3 } } catch (...) @@ -105,9 +129,9 @@ int main() } return true; }; - for (int attempt = 0; attempt < 20 && !sse_connected; ++attempt) + for (int attempt = 0; attempt < 60 && !sse_connected; ++attempt) { - auto res = cli.Get("/sse", receiver); + auto res = sse_client.Get("/sse", receiver); if (!res) { std::this_thread::sleep_for(std::chrono::milliseconds(200)); @@ -118,6 +142,7 @@ int main() } }); + // Wait for connection for (int i = 0; i < 500 && !sse_connected; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); if (!sse_connected) @@ -129,36 +154,29 @@ int main() return 1; } - // Wait for session_id to be extracted - for (int i = 0; i < 100; ++i) - { - std::lock_guard lock(m); - if (!session_id.empty()) - break; + // Wait for server to tell us the message endpoint (includes required session_id). + for (int i = 0; i < 500 && !have_endpoint; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - - std::string sid; - { - std::lock_guard lock(m); - sid = session_id; - } - - if (sid.empty()) + if (!have_endpoint) { server->stop(); if (sse_thread.joinable()) sse_thread.join(); - std::cerr << "Failed to extract session_id" << std::endl; + std::cerr << "Missing endpoint event" << std::endl; return 1; } + // Post three messages httplib::Client post("127.0.0.1", port); + std::string post_path; + { + std::lock_guard lock(endpoint_mutex); + post_path = message_endpoint; + } for (int i = 1; i <= 3; ++i) { - Json j = Json{{"n", i}}; - std::string post_url = "/messages?session_id=" + sid; - auto res = post.Post(post_url, j.dump(), "application/json"); + Json j = {{"jsonrpc", "2.0"}, {"id", i}, {"method", "echo"}, {"params", {{"n", i}}}}; + auto res = post.Post(post_path, j.dump(), "application/json"); if (!res || res->status != 200) { server->stop(); @@ -169,11 +187,14 @@ int main() } } + // Wait briefly for all events for (int i = 0; i < 200; ++i) { - std::lock_guard lock(m); - if (seen.size() >= 3) - break; + { + std::lock_guard lock(seen_mutex); + if (seen.size() >= 3) + break; + } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -181,11 +202,20 @@ int main() if (sse_thread.joinable()) sse_thread.join(); - if (seen.size() != 3) { - std::cerr << "expected 3 events, got " << seen.size() << "\n"; - return 1; + std::lock_guard lock(seen_mutex); + if (seen.size() != 3) + { + std::cerr << "expected 3 events, got " << seen.size() << "\n"; + return 1; + } + if (seen[0] != 1 || seen[1] != 2 || seen[2] != 3) + { + std::cerr << "unexpected event sequence\n"; + return 1; + } } + std::cout << "ok\n"; return 0; } diff --git a/include/fastmcpp/app.hpp b/include/fastmcpp/app.hpp index 23492c5..f3aa6cc 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -49,8 +49,8 @@ struct CustomRouteResponse /// fixed forwarding from mounted servers). struct CustomRoute { - std::string method; // GET, POST, etc. (uppercase) - std::string path; // Absolute path, e.g. "/health" — must start with '/' + std::string method; // GET, POST, etc. (uppercase) + std::string path; // Absolute path, e.g. "/health" — must start with '/' std::function handler; }; diff --git a/include/fastmcpp/client/client.hpp b/include/fastmcpp/client/client.hpp index 6f4afaf..4db7301 100644 --- a/include/fastmcpp/client/client.hpp +++ b/include/fastmcpp/client/client.hpp @@ -18,8 +18,8 @@ #include #include #include -#include #include +#include #include #include #include diff --git a/include/fastmcpp/client/transports.hpp b/include/fastmcpp/client/transports.hpp index 801cdb7..61a4c11 100644 --- a/include/fastmcpp/client/transports.hpp +++ b/include/fastmcpp/client/transports.hpp @@ -124,8 +124,7 @@ class SseClientTransport : public ITransport, /// @param sse_path Path for SSE endpoint (default: "/sse") /// @param messages_path Path for message endpoint (default: "/messages") explicit SseClientTransport(std::string base_url, std::string sse_path = "/sse", - std::string messages_path = "/messages", - bool verify_ssl = true); + std::string messages_path = "/messages", bool verify_ssl = true); ~SseClientTransport(); diff --git a/include/fastmcpp/client/types.hpp b/include/fastmcpp/client/types.hpp index e6117f1..fb5720b 100644 --- a/include/fastmcpp/client/types.hpp +++ b/include/fastmcpp/client/types.hpp @@ -64,8 +64,8 @@ using ContentBlock = std::variant version; ///< Component version metadata - std::optional title; ///< Human-readable title + std::optional version; ///< Component version metadata + std::optional title; ///< Human-readable title std::optional description; fastmcpp::Json inputSchema; ///< JSON Schema for tool input std::optional outputSchema; ///< JSON Schema for structured output @@ -134,8 +134,8 @@ struct ResourceInfo { std::string uri; std::string name; - std::optional version; ///< Component version metadata - std::optional title; ///< Human-readable title + std::optional version; ///< Component version metadata + std::optional title; ///< Human-readable title std::optional description; std::optional mimeType; std::optional annotations; @@ -221,8 +221,8 @@ struct PromptArgument struct PromptInfo { std::string name; - std::optional version; ///< Component version metadata - std::optional title; ///< Human-readable title + std::optional version; ///< Component version metadata + std::optional title; ///< Human-readable title std::optional description; std::optional> arguments; std::optional> icons; ///< Icons for UI display diff --git a/include/fastmcpp/providers/transforms/catalog.hpp b/include/fastmcpp/providers/transforms/catalog.hpp index 904c429..61babe8 100644 --- a/include/fastmcpp/providers/transforms/catalog.hpp +++ b/include/fastmcpp/providers/transforms/catalog.hpp @@ -61,8 +61,7 @@ class CatalogTransform : public Transform // ---- Subclass hooks (override these, not list_*) ---- - virtual std::vector - transform_tools(const ListToolsNext& call_next) const + virtual std::vector transform_tools(const ListToolsNext& call_next) const { return call_next(); } @@ -79,8 +78,7 @@ class CatalogTransform : public Transform return call_next(); } - virtual std::vector - transform_prompts(const ListPromptsNext& call_next) const + virtual std::vector transform_prompts(const ListPromptsNext& call_next) const { return call_next(); } @@ -123,15 +121,13 @@ class CatalogTransform : public Transform return result; } - std::vector - get_resource_catalog(const ListResourcesNext& call_next) const + std::vector get_resource_catalog(const ListResourcesNext& call_next) const { BypassGuard guard(bypass_); return call_next(); } - std::vector - get_prompt_catalog(const ListPromptsNext& call_next) const + std::vector get_prompt_catalog(const ListPromptsNext& call_next) const { BypassGuard guard(bypass_); return call_next(); diff --git a/include/fastmcpp/providers/transforms/search/base.hpp b/include/fastmcpp/providers/transforms/search/base.hpp index 0b83ea5..d554111 100644 --- a/include/fastmcpp/providers/transforms/search/base.hpp +++ b/include/fastmcpp/providers/transforms/search/base.hpp @@ -76,7 +76,9 @@ class BaseSearchTransform : public CatalogTransform SearchResultSerializer search_result_serializer; }; - explicit BaseSearchTransform(Options opts = {}) + BaseSearchTransform() : BaseSearchTransform(Options{}) {} + + explicit BaseSearchTransform(Options opts) : max_results_(opts.max_results), always_visible_(opts.always_visible.begin(), opts.always_visible.end()), search_tool_name_(std::move(opts.search_tool_name)), @@ -87,8 +89,7 @@ class BaseSearchTransform : public CatalogTransform search_result_serializer_ = serialize_tools_for_output_json; } - std::vector - transform_tools(const ListToolsNext& call_next) const override + std::vector transform_tools(const ListToolsNext& call_next) const override { auto tools = call_next(); std::vector result; @@ -115,8 +116,8 @@ class BaseSearchTransform : public CatalogTransform } /// Perform search over tools. Subclasses implement this. - virtual std::vector - do_search(const std::vector& tools, const std::string& query) const = 0; + virtual std::vector do_search(const std::vector& tools, + const std::string& query) const = 0; protected: /// Create the search tool. Subclasses provide the implementation via do_search. @@ -126,25 +127,22 @@ class BaseSearchTransform : public CatalogTransform { Json input_schema = { {"type", "object"}, - {"properties", - Json{{"name", - Json{{"type", "string"}, {"description", "The name of the tool to call"}}}, - {"arguments", - Json{{"type", "object"}, - {"description", "Arguments to pass to the tool"}, - {"additionalProperties", true}}}}}, + {"properties", Json{{"name", Json{{"type", "string"}, + {"description", "The name of the tool to call"}}}, + {"arguments", Json{{"type", "object"}, + {"description", "Arguments to pass to the tool"}, + {"additionalProperties", true}}}}}, {"required", Json::array({"name"})}}; tools::Tool::Fn fn = [](const Json& /*args*/) -> Json { - return Json{{"content", - Json::array({Json{{"type", "text"}, - {"text", "call_tool proxy: use tools/call directly " - "with the discovered tool name"}}})}}; + return Json{ + {"content", Json::array({Json{{"type", "text"}, + {"text", "call_tool proxy: use tools/call directly " + "with the discovered tool name"}}})}}; }; - return tools::Tool(call_tool_name_, std::move(input_schema), Json::object(), - std::move(fn)); + return tools::Tool(call_tool_name_, std::move(input_schema), Json::object(), std::move(fn)); } int max_results() const diff --git a/include/fastmcpp/providers/transforms/search/bm25.hpp b/include/fastmcpp/providers/transforms/search/bm25.hpp index 8ee9ee6..4eafb9c 100644 --- a/include/fastmcpp/providers/transforms/search/bm25.hpp +++ b/include/fastmcpp/providers/transforms/search/bm25.hpp @@ -87,8 +87,7 @@ class BM25Index auto df_it = df_.find(token); if (df_it == df_.end()) continue; - double idf = - std::log((n_ - df_it->second + 0.5) / (df_it->second + 0.5) + 1.0); + double idf = std::log((n_ - df_it->second + 0.5) / (df_it->second + 0.5) + 1.0); for (int i = 0; i < n_; ++i) { auto tf_it = tf_[i].find(token); @@ -104,8 +103,7 @@ class BM25Index std::vector indices(n_); std::iota(indices.begin(), indices.end(), 0); - std::partial_sort(indices.begin(), - indices.begin() + std::min(top_k, n_), indices.end(), + std::partial_sort(indices.begin(), indices.begin() + std::min(top_k, n_), indices.end(), [&](int a, int b_idx) { return scores[a] > scores[b_idx]; }); std::vector result; @@ -115,8 +113,14 @@ class BM25Index return result; } - double k1() const { return k1_; } - double b() const { return b_; } + double k1() const + { + return k1_; + } + double b() const + { + return b_; + } private: double k1_; @@ -142,8 +146,8 @@ class BM25SearchTransform : public BaseSearchTransform public: explicit BM25SearchTransform(Options opts = {}) : BaseSearchTransform(std::move(opts)) {} - std::vector - do_search(const std::vector& tools, const std::string& query) const override + std::vector do_search(const std::vector& tools, + const std::string& query) const override { // Rebuild index if catalog changed auto hash = catalog_hash(tools); @@ -173,17 +177,15 @@ class BM25SearchTransform : public BaseSearchTransform Json input_schema = { {"type", "object"}, {"properties", - Json{{"query", - Json{{"type", "string"}, - {"description", "Natural language query to search for tools"}}}}}, + Json{{"query", Json{{"type", "string"}, + {"description", "Natural language query to search for tools"}}}}}, {"required", Json::array({"query"})}}; tools::Tool::Fn fn = [](const Json& /*args*/) -> Json { - return Json{{"content", - Json::array( - {Json{{"type", "text"}, - {"text", "Search tool: use with query argument"}}})}}; + return Json{ + {"content", Json::array({Json{{"type", "text"}, + {"text", "Search tool: use with query argument"}}})}}; }; return tools::Tool(search_tool_name(), std::move(input_schema), Json::object(), diff --git a/include/fastmcpp/providers/transforms/search/regex.hpp b/include/fastmcpp/providers/transforms/search/regex.hpp index d2848db..56f73a6 100644 --- a/include/fastmcpp/providers/transforms/search/regex.hpp +++ b/include/fastmcpp/providers/transforms/search/regex.hpp @@ -18,8 +18,8 @@ class RegexSearchTransform : public BaseSearchTransform public: explicit RegexSearchTransform(Options opts = {}) : BaseSearchTransform(std::move(opts)) {} - std::vector - do_search(const std::vector& tools, const std::string& query) const override + std::vector do_search(const std::vector& tools, + const std::string& query) const override { std::regex pattern; try @@ -51,18 +51,16 @@ class RegexSearchTransform : public BaseSearchTransform Json input_schema = { {"type", "object"}, {"properties", - Json{{"pattern", - Json{{"type", "string"}, - {"description", "Regex pattern to match against tool names, " - "descriptions, and parameters"}}}}}, + Json{{"pattern", Json{{"type", "string"}, + {"description", "Regex pattern to match against tool names, " + "descriptions, and parameters"}}}}}, {"required", Json::array({"pattern"})}}; tools::Tool::Fn fn = [](const Json& /*args*/) -> Json { return Json{{"content", - Json::array( - {Json{{"type", "text"}, - {"text", "Search tool: use with pattern argument"}}})}}; + Json::array({Json{{"type", "text"}, + {"text", "Search tool: use with pattern argument"}}})}}; }; return tools::Tool(search_tool_name(), std::move(input_schema), Json::object(), diff --git a/include/fastmcpp/resources/template.hpp b/include/fastmcpp/resources/template.hpp index 458307b..ace59d1 100644 --- a/include/fastmcpp/resources/template.hpp +++ b/include/fastmcpp/resources/template.hpp @@ -29,8 +29,8 @@ enum class ParamKind struct TemplateParameter { std::string name; - bool is_wildcard{false}; // {var*} vs {var} - bool is_query{false}; // {?var} query param + bool is_wildcard{false}; // {var*} vs {var} + bool is_query{false}; // {?var} query param ParamKind kind{ParamKind::String}; }; diff --git a/include/fastmcpp/server/http_server.hpp b/include/fastmcpp/server/http_server.hpp index f3f03b8..a277c39 100644 --- a/include/fastmcpp/server/http_server.hpp +++ b/include/fastmcpp/server/http_server.hpp @@ -13,7 +13,7 @@ namespace httplib { class Server; class Response; -} +} // namespace httplib namespace fastmcpp { @@ -38,8 +38,7 @@ class HttpServerWrapper * @param response_headers Additional HTTP headers added to responses */ HttpServerWrapper(std::shared_ptr core, std::string host = "127.0.0.1", - int port = 18080, std::string auth_token = "", - std::string cors_origin = "", + int port = 18080, std::string auth_token = "", std::string cors_origin = "", std::unordered_map response_headers = {}); ~HttpServerWrapper(); diff --git a/include/fastmcpp/server/streamable_http_server.hpp b/include/fastmcpp/server/streamable_http_server.hpp index 9237d50..0d8209a 100644 --- a/include/fastmcpp/server/streamable_http_server.hpp +++ b/include/fastmcpp/server/streamable_http_server.hpp @@ -57,8 +57,7 @@ class StreamableHttpServerWrapper */ explicit StreamableHttpServerWrapper( McpHandler handler, std::string host = "127.0.0.1", int port = 18080, - std::string mcp_path = "/mcp", std::string auth_token = "", - std::string cors_origin = "", + std::string mcp_path = "/mcp", std::string auth_token = "", std::string cors_origin = "", std::unordered_map response_headers = {}); ~StreamableHttpServerWrapper(); diff --git a/include/fastmcpp/tools/tool_transform.hpp b/include/fastmcpp/tools/tool_transform.hpp index 85590fe..99b21d8 100644 --- a/include/fastmcpp/tools/tool_transform.hpp +++ b/include/fastmcpp/tools/tool_transform.hpp @@ -99,10 +99,9 @@ build_transformed_schema(const Json& parent_schema, if (it != transform_args.end() && it->second.hide) continue; // hidden args do not occupy an effective slot - std::string effective = - (it != transform_args.end() && it->second.name.has_value()) - ? *it->second.name - : old_name; + std::string effective = (it != transform_args.end() && it->second.name.has_value()) + ? *it->second.name + : old_name; auto inserted = seen_owner.emplace(effective, old_name); if (!inserted.second) diff --git a/include/fastmcpp/util/versions.hpp b/include/fastmcpp/util/versions.hpp index 0ab8c09..b28f8e7 100644 --- a/include/fastmcpp/util/versions.hpp +++ b/include/fastmcpp/util/versions.hpp @@ -25,8 +25,7 @@ namespace detail inline bool is_digits(const std::string& s) { return !s.empty() && - std::all_of(s.begin(), s.end(), - [](unsigned char c) { return std::isdigit(c) != 0; }); + std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); } inline std::string strip_leading_zeros(const std::string& s) @@ -114,7 +113,8 @@ inline int compare(const std::optional& a, const std::optional struct DedupedEntry +template +struct DedupedEntry { T item; std::vector available_versions; @@ -124,8 +124,8 @@ template struct DedupedEntry /// group, and report the list of available concrete versions for each group /// (descending). Mirrors Python `dedupe_with_versions` from commit 03673d9f. template -std::vector> -dedupe_with_versions(const std::vector& items, KeyFn key_fn, VersionFn version_fn) +std::vector> dedupe_with_versions(const std::vector& items, KeyFn key_fn, + VersionFn version_fn) { // Preserve first-seen order of keys for stable output. std::vector key_order; @@ -147,10 +147,8 @@ dedupe_with_versions(const std::vector& items, KeyFn key_fn, VersionFn versio const auto& indices = by_key.at(key); size_t winner_idx = indices.front(); for (size_t i : indices) - { if (compare(version_fn(items[i]), version_fn(items[winner_idx])) > 0) winner_idx = i; - } std::vector versions; versions.reserve(indices.size()); diff --git a/src/app.cpp b/src/app.cpp index 2711788..1551868 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -553,10 +553,14 @@ std::vector FastMCP::list_all_tools_info() const info.execution = execution; } info.icons = tool.icons(); + if (tool.meta() && tool.meta()->is_object()) + info._meta = *tool.meta(); if (tool.app() && !tool.app()->empty()) { info.app = *tool.app(); - info._meta = Json{{"ui", *tool.app()}}; + if (!info._meta || !info._meta->is_object()) + info._meta = Json::object(); + (*info._meta)["ui"] = *tool.app(); } normalize_tool_info_schemas(info); result.push_back(info); diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index 9438f59..e70c774 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -822,10 +822,9 @@ fastmcpp::Json build_fastmcp_tool_result(const fastmcpp::Json& result, // Merge wrap_result into existing _meta if (wrap_result) { - fastmcpp::Json meta = - payload.contains("_meta") && payload["_meta"].is_object() - ? payload["_meta"] - : fastmcpp::Json::object(); + fastmcpp::Json meta = payload.contains("_meta") && payload["_meta"].is_object() + ? payload["_meta"] + : fastmcpp::Json::object(); meta["fastmcp"] = fastmcpp::Json{{"wrap_result", true}}; payload["_meta"] = std::move(meta); } @@ -998,7 +997,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, tools_array.push_back(make_tool_entry( name, desc, schema, tool.title(), tool.icons(), tool.output_schema(), - tool.task_support(), tool.sequential(), tool.app(), std::nullopt, + tool.task_support(), tool.sequential(), tool.app(), tool.meta(), tool.version(), tool.annotations())); } @@ -1373,7 +1372,8 @@ make_mcp_handler(const std::string& server_name, const std::string& version, std::string desc = tool.description() ? *tool.description() : ""; tools_array.push_back(make_tool_entry( name, desc, tool.input_schema(), tool.title(), tool.icons(), - tool.output_schema(), tool.task_support(), tool.sequential(), tool.app())); + tool.output_schema(), tool.task_support(), tool.sequential(), tool.app(), + tool.meta(), tool.version(), tool.annotations())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1554,7 +1554,8 @@ make_mcp_handler(const std::string& server_name, const std::string& version, std::string desc = tool.description() ? *tool.description() : ""; tools_array.push_back(make_tool_entry( name, desc, tool.input_schema(), tool.title(), tool.icons(), - tool.output_schema(), tool.task_support(), tool.sequential(), tool.app())); + tool.output_schema(), tool.task_support(), tool.sequential(), tool.app(), + tool.meta(), tool.version(), tool.annotations())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1999,8 +2000,7 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) tasks->enqueue_task( task_id, - [&app, name, args, has_output_schema, - wrap_result]() -> fastmcpp::Json + [&app, name, args, has_output_schema, wrap_result]() -> fastmcpp::Json { auto invoke_result = app.invoke_tool(name, args, false); return build_fastmcp_tool_result(invoke_result, has_output_schema, diff --git a/src/providers/transforms/resources_as_tools.cpp b/src/providers/transforms/resources_as_tools.cpp index c821f99..77665e1 100644 --- a/src/providers/transforms/resources_as_tools.cpp +++ b/src/providers/transforms/resources_as_tools.cpp @@ -38,10 +38,9 @@ tools::Tool ResourcesAsTools::make_list_resources_tool() const return Json{{"type", "text"}, {"text", result.dump(2)}}; }; - tools::Tool tool( - "list_resources", Json::object(), Json(), fn, std::nullopt, - std::optional("List available resources and resource templates"), - std::nullopt); + tools::Tool tool("list_resources", Json::object(), Json(), fn, std::nullopt, + std::optional("List available resources and resource templates"), + std::nullopt); tool.set_annotations(kReadOnlyAnnotations); return tool; } diff --git a/src/resources/template.cpp b/src/resources/template.cpp index 1ea742a..b07bada 100644 --- a/src/resources/template.cpp +++ b/src/resources/template.cpp @@ -38,7 +38,8 @@ Json coerce_param_value(const std::string& value, ParamKind kind, const std::str { case ParamKind::String: return Json(value); - case ParamKind::Boolean: { + case ParamKind::Boolean: + { const std::string lower = to_lower(value); if (lower == "true" || lower == "1" || lower == "yes") return Json(true); @@ -47,7 +48,8 @@ Json coerce_param_value(const std::string& value, ParamKind kind, const std::str throw fastmcpp::ValidationError("Invalid boolean value for " + param_name + ": '" + value + "'"); } - case ParamKind::Integer: { + case ParamKind::Integer: + { try { size_t consumed = 0; @@ -67,7 +69,8 @@ Json coerce_param_value(const std::string& value, ParamKind kind, const std::str value + "'"); } } - case ParamKind::Number: { + case ParamKind::Number: + { try { size_t consumed = 0; diff --git a/src/server/http_server.cpp b/src/server/http_server.cpp index 5217cf9..8c46d72 100644 --- a/src/server/http_server.cpp +++ b/src/server/http_server.cpp @@ -68,13 +68,29 @@ bool HttpServerWrapper::start() svr_->Options(R"(/(.*))", [this](const httplib::Request&, httplib::Response& res) { - res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.set_header("Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, PATCH, OPTIONS"); res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id"); apply_additional_response_headers(res); res.status = 204; }); + auto authorize_or_401 = [this](const httplib::Request& req, httplib::Response& res) -> bool + { + if (auth_token_.empty()) + return true; + + auto auth_it = req.headers.find("Authorization"); + if (auth_it != req.headers.end() && check_auth(auth_it->second)) + return true; + + apply_additional_response_headers(res); + res.status = 401; + res.set_content("{\"error\":\"Unauthorized\"}", "application/json"); + return false; + }; + // Register user-supplied custom routes BEFORE the catch-all so they // shadow JSON-RPC dispatch on those paths. Parity with Python fastmcp // `custom_route()` aggregation (commit 68e76fea forwards routes from @@ -83,8 +99,12 @@ bool HttpServerWrapper::start() { if (!route.handler) continue; - auto handler = [this, route](const httplib::Request& req, httplib::Response& res) + auto handler = + [this, route, authorize_or_401](const httplib::Request& req, httplib::Response& res) { + if (!authorize_or_401(req, res)) + return; + apply_additional_response_headers(res); fastmcpp::CustomRouteRequest cr; cr.method = route.method; @@ -122,19 +142,10 @@ bool HttpServerWrapper::start() // Generic POST: / svr_->Post(R"(/(.*))", - [this](const httplib::Request& req, httplib::Response& res) + [this, authorize_or_401](const httplib::Request& req, httplib::Response& res) { - // Security: Check authentication if configured - if (!auth_token_.empty()) - { - auto auth_it = req.headers.find("Authorization"); - if (auth_it == req.headers.end() || !check_auth(auth_it->second)) - { - res.status = 401; - res.set_content("{\"error\":\"Unauthorized\"}", "application/json"); - return; - } - } + if (!authorize_or_401(req, res)) + return; apply_additional_response_headers(res); diff --git a/src/server/streamable_http_server.cpp b/src/server/streamable_http_server.cpp index 358a8d1..52cd2cd 100644 --- a/src/server/streamable_http_server.cpp +++ b/src/server/streamable_http_server.cpp @@ -15,11 +15,9 @@ namespace fastmcpp::server { -StreamableHttpServerWrapper::StreamableHttpServerWrapper(McpHandler handler, std::string host, - int port, std::string mcp_path, - std::string auth_token, - std::string cors_origin, - std::unordered_map response_headers) +StreamableHttpServerWrapper::StreamableHttpServerWrapper( + McpHandler handler, std::string host, int port, std::string mcp_path, std::string auth_token, + std::string cors_origin, std::unordered_map response_headers) : handler_(std::move(handler)), host_(std::move(host)), requested_port_(port), mcp_path_(std::move(mcp_path)), auth_token_(std::move(auth_token)), response_headers_(std::move(response_headers)) diff --git a/tests/app/custom_route_forwarding.cpp b/tests/app/custom_route_forwarding.cpp index c24e701..96c6bec 100644 --- a/tests/app/custom_route_forwarding.cpp +++ b/tests/app/custom_route_forwarding.cpp @@ -39,7 +39,8 @@ static CustomRoute make_route(const std::string& method, const std::string& path CustomRoute r; r.method = method; r.path = path; - r.handler = [body](const CustomRouteRequest&) { + r.handler = [body](const CustomRouteRequest&) + { CustomRouteResponse resp; resp.body = body; resp.content_type = "text/plain"; @@ -197,6 +198,96 @@ static int test_http_end_to_end_serves_route() return 0; } +static int test_http_custom_route_requires_auth() +{ + std::cout << " test_http_custom_route_requires_auth..." << std::endl; + FastMCP app("secure", "1.0.0"); + app.add_custom_route(make_route("GET", "/health", "ok")); + + auto core = std::make_shared(app.server()); + + int port = 0; + std::unique_ptr http; + for (int candidate = 18441; candidate <= 18460; ++candidate) + { + auto trial = std::make_unique(core, "127.0.0.1", candidate, + "secret-token"); + trial->set_custom_routes(app.all_custom_routes()); + if (trial->start()) + { + port = trial->port(); + http = std::move(trial); + break; + } + } + ASSERT_TRUE(http && port > 0, "HTTP server started"); + + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + + httplib::Client client("127.0.0.1", port); + client.set_connection_timeout(std::chrono::seconds(2)); + client.set_read_timeout(std::chrono::seconds(2)); + + auto unauthorized = client.Get("/health"); + ASSERT_TRUE(unauthorized != nullptr, "unauthorized GET returned a response"); + ASSERT_EQ(unauthorized->status, 401, "missing bearer token rejected"); + + httplib::Headers headers = {{"Authorization", "Bearer secret-token"}}; + auto authorized = client.Get("/health", headers); + ASSERT_TRUE(authorized != nullptr, "authorized GET returned a response"); + ASSERT_EQ(authorized->status, 200, "authorized request succeeded"); + ASSERT_EQ(authorized->body, std::string("ok"), "authorized body"); + + http->stop(); + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_http_custom_route_options_advertises_methods() +{ + std::cout << " test_http_custom_route_options_advertises_methods..." << std::endl; + FastMCP app("cors", "1.0.0"); + app.add_custom_route(make_route("PATCH", "/mutate", "patched")); + + auto core = std::make_shared(app.server()); + + int port = 0; + std::unique_ptr http; + for (int candidate = 18461; candidate <= 18480; ++candidate) + { + auto trial = std::make_unique(core, "127.0.0.1", candidate); + trial->set_custom_routes(app.all_custom_routes()); + if (trial->start()) + { + port = trial->port(); + http = std::move(trial); + break; + } + } + ASSERT_TRUE(http && port > 0, "HTTP server started"); + + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + + httplib::Client client("127.0.0.1", port); + client.set_connection_timeout(std::chrono::seconds(2)); + client.set_read_timeout(std::chrono::seconds(2)); + + auto resp = client.Options("/mutate"); + ASSERT_TRUE(resp != nullptr, "OPTIONS request returned a response"); + ASSERT_EQ(resp->status, 204, "preflight status"); + auto methods = resp->get_header_value("Access-Control-Allow-Methods"); + ASSERT_TRUE(methods.find("GET") != std::string::npos, "GET advertised"); + ASSERT_TRUE(methods.find("POST") != std::string::npos, "POST advertised"); + ASSERT_TRUE(methods.find("PUT") != std::string::npos, "PUT advertised"); + ASSERT_TRUE(methods.find("DELETE") != std::string::npos, "DELETE advertised"); + ASSERT_TRUE(methods.find("PATCH") != std::string::npos, "PATCH advertised"); + ASSERT_TRUE(methods.find("OPTIONS") != std::string::npos, "OPTIONS advertised"); + + http->stop(); + std::cout << " PASS" << std::endl; + return 0; +} + int main() { std::cout << "Custom Route Forwarding Tests" << std::endl; @@ -208,6 +299,8 @@ int main() failures += test_aggregate_from_mounted_child(); failures += test_aggregate_dedups_collisions(); failures += test_http_end_to_end_serves_route(); + failures += test_http_custom_route_requires_auth(); + failures += test_http_custom_route_options_advertises_methods(); std::cout << std::endl; if (failures == 0) { diff --git a/tests/mcp/handler.cpp b/tests/mcp/handler.cpp index 1745149..ce70b43 100644 --- a/tests/mcp/handler.cpp +++ b/tests/mcp/handler.cpp @@ -74,17 +74,14 @@ int main() { tools::ToolManager tm2; Json schema = {{"type", "object"}, {"properties", Json::object()}}; - tools::Tool versioned{"versioned", schema, Json(), - [](const Json&) { return 42; }}; + tools::Tool versioned{"versioned", schema, Json(), [](const Json&) { return 42; }}; versioned.set_version("2.0.0"); tm2.register_tool(versioned); - tools::Tool plain{"plain", schema, Json(), - [](const Json&) { return 1; }}; + tools::Tool plain{"plain", schema, Json(), [](const Json&) { return 1; }}; tm2.register_tool(plain); auto handler2 = mcp::make_mcp_handler("ver_test", "1.0.0", tm2); - auto list2 = - handler2(Json{{"jsonrpc", "2.0"}, {"id", 10}, {"method", "tools/list"}}); + auto list2 = handler2(Json{{"jsonrpc", "2.0"}, {"id", 10}, {"method", "tools/list"}}); bool checked_versioned = false, checked_plain = false; for (const auto& t : list2["result"]["tools"]) { @@ -109,8 +106,7 @@ int main() tools::ToolManager tm3; Json schema = {{"type", "object"}, {"properties", Json::object()}}; // Json() = null → no outputSchema emitted - tools::Tool no_schema{"no_schema", schema, Json(), - [](const Json&) { return 1; }}; + tools::Tool no_schema{"no_schema", schema, Json(), [](const Json&) { return 1; }}; // Json{{"type","object"}} → outputSchema present tools::Tool with_schema{"with_schema", schema, Json{{"type", "object"}}, [](const Json&) { return 1; }}; @@ -118,8 +114,7 @@ int main() tm3.register_tool(with_schema); auto handler3 = mcp::make_mcp_handler("schema_test", "1.0.0", tm3); - auto list3 = - handler3(Json{{"jsonrpc", "2.0"}, {"id", 20}, {"method", "tools/list"}}); + auto list3 = handler3(Json{{"jsonrpc", "2.0"}, {"id", 20}, {"method", "tools/list"}}); bool checked_no = false, checked_with = false; for (const auto& t : list3["result"]["tools"]) { diff --git a/tests/mcp/server_handler.cpp b/tests/mcp/server_handler.cpp index 31fcd4b..c9ddd6f 100644 --- a/tests/mcp/server_handler.cpp +++ b/tests/mcp/server_handler.cpp @@ -28,8 +28,7 @@ int main() AudioContent audio; audio.data = "aGVsbG8="; // base64("hello") audio.mimeType = "audio/wav"; - Json content = - Json::array({TextContent{"text", "Audio attached"}, audio}); + Json content = Json::array({TextContent{"text", "Audio attached"}, audio}); return Json{{"content", content}}; }); @@ -62,11 +61,10 @@ int main() assert(content[1]["mimeType"] == "image/png"); // call audio_tool — verify audio block preserved through handler - Json audio_call = { - {"jsonrpc", "2.0"}, - {"id", 10}, - {"method", "tools/call"}, - {"params", Json{{"name", "audio_tool"}, {"arguments", Json::object()}}}}; + Json audio_call = {{"jsonrpc", "2.0"}, + {"id", 10}, + {"method", "tools/call"}, + {"params", Json{{"name", "audio_tool"}, {"arguments", Json::object()}}}}; auto audio_resp = handler(audio_call); auto audio_content = audio_resp["result"]["content"]; assert(audio_content.size() == 2); diff --git a/tests/providers/catalog_dedup.cpp b/tests/providers/catalog_dedup.cpp index 4e6bbce..036a939 100644 --- a/tests/providers/catalog_dedup.cpp +++ b/tests/providers/catalog_dedup.cpp @@ -4,7 +4,8 @@ // test_catalog.py). #include "fastmcpp/app.hpp" -#include "fastmcpp/providers/local_provider.hpp" +#include "fastmcpp/mcp/handler.hpp" +#include "fastmcpp/providers/provider.hpp" #include "fastmcpp/providers/transforms/catalog.hpp" #include "fastmcpp/providers/transforms/version_filter.hpp" #include "fastmcpp/util/versions.hpp" @@ -53,6 +54,30 @@ class NoopCatalogTransform : public CatalogTransform { }; +class PipelineCatalogTransform : public CatalogTransform +{ + public: + std::vector + transform_tools(const providers::transforms::ListToolsNext& call_next) const override + { + return get_tool_catalog(call_next); + } +}; + +class StaticToolProvider : public providers::Provider +{ + public: + explicit StaticToolProvider(std::vector tools) : tools_(std::move(tools)) {} + + std::vector list_tools() const override + { + return tools_; + } + + private: + std::vector tools_; +}; + // Build a list_tools call_next that simply returns a fixed tool list (we're // exercising the dedup logic at the catalog accessor, not the pipeline). auto fixed_call_next(const std::vector& tools) @@ -145,7 +170,7 @@ static int test_get_tool_catalog_mixed_versioned_unversioned() std::cout << " test_get_tool_catalog_mixed_versioned_unversioned..." << std::endl; NoopCatalogTransform t; auto next = fixed_call_next({ - make_tool("standalone", ""), // unversioned, distinct key + make_tool("standalone", ""), // unversioned, distinct key make_tool("greet", "1"), make_tool("greet", "2"), }); @@ -155,8 +180,7 @@ static int test_get_tool_catalog_mixed_versioned_unversioned() // standalone first (insertion order preserved); greet@2 second. ASSERT_EQ(result[0].name(), std::string("standalone"), "standalone first"); ASSERT_TRUE(!result[0].version().has_value(), "standalone unversioned"); - ASSERT_TRUE(!result[0].meta().has_value() || - !result[0].meta()->contains("fastmcp") || + ASSERT_TRUE(!result[0].meta().has_value() || !result[0].meta()->contains("fastmcp") || !(*result[0].meta())["fastmcp"].contains("versions"), "no versions meta when single-version (or no version) entry"); @@ -198,7 +222,8 @@ static int test_version_filter_applied_before_catalog() make_tool("greet", "2"), make_tool("greet", "3"), }; - auto filtered_next = [&]() { + auto filtered_next = [&]() + { std::vector filtered; for (const auto& tool : raw) { @@ -220,6 +245,54 @@ static int test_version_filter_applied_before_catalog() return 0; } +static int test_metadata_survives_tool_info_and_mcp_serialization() +{ + std::cout << " test_metadata_survives_tool_info_and_mcp_serialization..." << std::endl; + + auto v1 = make_tool("greet", "1"); + auto v3 = make_tool("greet", "3"); + AppConfig app_config; + app_config.resource_uri = "ui://widgets/greet.html"; + v3.set_app(app_config); + auto provider = std::make_shared(std::vector{v1, v3}); + provider->add_transform(std::make_shared()); + + FastMCP app("catalog", "1.0.0"); + app.add_provider(provider); + auto tools = app.list_all_tools_info(); + ASSERT_EQ(tools.size(), 1u, "deduped tool list"); + ASSERT_TRUE(tools[0]._meta.has_value(), "tool info carries _meta"); + ASSERT_TRUE((*tools[0]._meta).contains("fastmcp"), "fastmcp block present"); + ASSERT_TRUE((*tools[0]._meta)["fastmcp"].contains("versions"), "versions surfaced"); + ASSERT_TRUE((*tools[0]._meta).contains("ui"), "ui metadata preserved"); + auto handler = mcp::make_mcp_handler(app); + Json req = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "tools/list"}}; + Json resp = handler(req); + ASSERT_TRUE(resp.contains("result") && resp["result"].contains("tools"), "tools/list result"); + ASSERT_EQ(resp["result"]["tools"].size(), 1u, "one serialized tool"); + const auto& tool = resp["result"]["tools"][0]; + ASSERT_TRUE(tool.contains("_meta") && tool["_meta"].is_object(), "serialized _meta"); + ASSERT_TRUE(tool["_meta"].contains("fastmcp"), "serialized fastmcp block"); + ASSERT_TRUE(tool["_meta"]["fastmcp"].contains("versions"), "serialized versions"); + ASSERT_TRUE(tool["_meta"]["fastmcp"]["versions"].is_array(), "serialized versions array"); + std::vector versions; + for (const auto& version_json : tool["_meta"]["fastmcp"]["versions"]) + { + ASSERT_TRUE(version_json.is_string(), "serialized version value is string"); + versions.push_back(version_json.get()); + } + ASSERT_EQ(versions.size(), 2u, "two versions serialized"); + ASSERT_TRUE(versions[0] == "3" && versions[1] == "1", "serialized version order"); + ASSERT_TRUE(tool["_meta"].contains("ui"), "serialized ui preserved"); + ASSERT_TRUE(tool["_meta"]["ui"].is_object(), "serialized ui object"); + ASSERT_TRUE(tool["_meta"]["ui"].value("resourceUri", std::string{}) == + "ui://widgets/greet.html", + "serialized ui value preserved"); + + std::cout << " PASS" << std::endl; + return 0; +} + int main() { std::cout << "CatalogTransform Dedup + Ordering Tests" << std::endl; @@ -232,6 +305,7 @@ int main() failures += test_get_tool_catalog_mixed_versioned_unversioned(); failures += test_no_meta_for_single_version(); failures += test_version_filter_applied_before_catalog(); + failures += test_metadata_survives_tool_info_and_mcp_serialization(); std::cout << std::endl; if (failures == 0) { diff --git a/tests/providers/test_catalog_search_transforms.cpp b/tests/providers/test_catalog_search_transforms.cpp index f1d6889..860642a 100644 --- a/tests/providers/test_catalog_search_transforms.cpp +++ b/tests/providers/test_catalog_search_transforms.cpp @@ -156,14 +156,12 @@ void test_regex_transform_list_tools() bool has_pinned = false, has_search = false, has_call = false; for (const auto& t : tools) - { if (t.name() == "tool_a") has_pinned = true; else if (t.name() == "search_tools") has_search = true; else if (t.name() == "call_tool") has_call = true; - } assert(has_pinned); assert(has_search); assert(has_call); diff --git a/tests/resources/template_query_params.cpp b/tests/resources/template_query_params.cpp index 8868748..4760513 100644 --- a/tests/resources/template_query_params.cpp +++ b/tests/resources/template_query_params.cpp @@ -50,9 +50,7 @@ static ResourceTemplate make_typed_template() }}, }; t.provider = [](const Json& params) -> ResourceContent - { - return ResourceContent{"search://echo", "application/json", params.dump()}; - }; + { return ResourceContent{"search://echo", "application/json", params.dump()}; }; t.parse(); return t; } @@ -62,7 +60,6 @@ static int test_kind_populated_from_schema() std::cout << " test_kind_populated_from_schema..." << std::endl; auto t = make_typed_template(); for (const auto& p : t.parsed_params) - { if (p.name == "query") ASSERT_TRUE(p.kind == ParamKind::String, "query kind"); else if (p.name == "limit" || p.name == "offset") @@ -71,7 +68,6 @@ static int test_kind_populated_from_schema() ASSERT_TRUE(p.kind == ParamKind::Boolean, "bool kind"); else if (p.name == "score") ASSERT_TRUE(p.kind == ParamKind::Number, "number kind"); - } std::cout << " PASS" << std::endl; return 0; } @@ -179,7 +175,10 @@ static int test_build_typed_params() std::cout << " test_build_typed_params..." << std::endl; auto t = make_typed_template(); std::unordered_map raw{ - {"query", "apple"}, {"limit", "5"}, {"verbose", "yes"}, {"score", "0.9"}, + {"query", "apple"}, + {"limit", "5"}, + {"verbose", "yes"}, + {"score", "0.9"}, }; Json p = t.build_typed_params(raw); ASSERT_TRUE(p["query"].is_string() && p["query"].get() == "apple", "query"); diff --git a/tests/server/auth_cors_security.cpp b/tests/server/auth_cors_security.cpp index 12350f7..bfc8d3a 100644 --- a/tests/server/auth_cors_security.cpp +++ b/tests/server/auth_cors_security.cpp @@ -333,9 +333,9 @@ int main() auto srv = std::make_shared(); srv->route("test", [](const Json&) { return Json{{"result", "ok"}}; }); - HttpServerWrapper http_server(srv, "127.0.0.1", 18606, "", "", - {{"Access-Control-Allow-Origin", "*"}, - {"X-Custom-Header", "custom-value"}}); + HttpServerWrapper http_server( + srv, "127.0.0.1", 18606, "", "", + {{"Access-Control-Allow-Origin", "*"}, {"X-Custom-Header", "custom-value"}}); if (!http_server.start()) { std::cerr << "Failed to start HTTP server\n"; diff --git a/tests/server/sse.cpp b/tests/server/sse.cpp index d565d70..ce9030e 100644 --- a/tests/server/sse.cpp +++ b/tests/server/sse.cpp @@ -327,7 +327,8 @@ int main() } Json throwing_notification = {{"jsonrpc", "2.0"}, {"method", "notifications/throw"}}; - auto throwing_res = post_client.Post(post_url, throwing_notification.dump(), "application/json"); + auto throwing_res = + post_client.Post(post_url, throwing_notification.dump(), "application/json"); if (!throwing_res || throwing_res->status != 202 || !throwing_res->body.empty()) { std::cerr << "Throwing notification should still return 202 with empty body\n"; @@ -350,10 +351,8 @@ int main() return 1; } - Json request = {{"jsonrpc", "2.0"}, - {"id", 1}, - {"method", "echo"}, - {"params", {{"message", "Hello SSE"}}}}; + Json request = { + {"jsonrpc", "2.0"}, {"id", 1}, {"method", "echo"}, {"params", {{"message", "Hello SSE"}}}}; auto post_res = post_client.Post(post_url, request.dump(), "application/json"); if (!post_res || post_res->status != 200) diff --git a/tests/server/streaming_sse.cpp b/tests/server/streaming_sse.cpp index 90feac4..1f3a72a 100644 --- a/tests/server/streaming_sse.cpp +++ b/tests/server/streaming_sse.cpp @@ -16,8 +16,14 @@ using fastmcpp::server::SseServerWrapper; int main() { - // Echo handler: returns posted JSON unchanged - auto handler = [](const Json& request) -> Json { return request; }; + // Echo handler: returns a minimal JSON-RPC response carrying the posted value. + auto handler = [](const Json& request) -> Json + { + Json response = {{"jsonrpc", "2.0"}, + {"id", request.value("id", Json(nullptr))}, + {"result", request.value("params", Json::object())}}; + return response; + }; // Choose port with fallback range int port = -1; @@ -100,17 +106,18 @@ int main() continue; } - // Parse "data: {json}" events and collect n values + // Parse "data: {json}" events and collect result.n values. if (block.rfind("data: ", 0) == 0) { std::string json_str = block.substr(6); try { Json j = Json::parse(json_str); - if (j.contains("n")) + if (j.contains("result") && j["result"].is_object() && + j["result"].contains("n")) { std::lock_guard lock(seen_mutex); - seen.push_back(j["n"].get()); + seen.push_back(j["result"]["n"].get()); if (seen.size() >= 3) return false; // stop after 3 } @@ -168,7 +175,7 @@ int main() } for (int i = 1; i <= 3; ++i) { - Json j = Json{{"n", i}}; + Json j = {{"jsonrpc", "2.0"}, {"id", i}, {"method", "echo"}, {"params", {{"n", i}}}}; auto res = post.Post(post_path, j.dump(), "application/json"); if (!res || res->status != 200) { diff --git a/tests/tools/test_tool_transform.cpp b/tests/tools/test_tool_transform.cpp index 9588c96..491d7d4 100644 --- a/tests/tools/test_tool_transform.cpp +++ b/tests/tools/test_tool_transform.cpp @@ -457,12 +457,12 @@ void test_hidden_does_not_block_rename_into_its_slot() std::cout << " test_hidden_does_not_block_rename_into_its_slot... " << std::flush; auto add_tool = create_add_tool(); std::unordered_map transforms; - transforms["y"] = make_hidden(Json(7)); // y is hidden, slot freed - transforms["x"] = make_rename("y"); // rename x -> y is OK now + transforms["y"] = make_hidden(Json(7)); // y is hidden, slot freed + transforms["x"] = make_rename("y"); // rename x -> y is OK now auto t = TransformedTool::from_tool(add_tool, std::nullopt, std::nullopt, transforms); auto schema = t.input_schema(); assert(schema["properties"].contains("y")); - auto result = t.invoke(Json{{"y", 5}}); // y maps back to x, hidden y default = 7 + auto result = t.invoke(Json{{"y", 5}}); // y maps back to x, hidden y default = 7 assert(result["result"].get() == 12); std::cout << "PASSED\n"; } From 8587cfaa604884963d50a33824c97122584d9dbc Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sun, 19 Apr 2026 12:06:50 -0700 Subject: [PATCH 3/4] Fix custom route request forwarding regressions --- include/fastmcpp/app.hpp | 5 +- .../fastmcpp/providers/transforms/catalog.hpp | 4 +- include/fastmcpp/util/http_methods.hpp | 35 +++++ src/app.cpp | 5 +- src/server/http_server.cpp | 13 +- tests/app/custom_route_forwarding.cpp | 136 +++++++++++++++++- tests/providers/catalog_dedup.cpp | 104 ++++++++++++++ 7 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 include/fastmcpp/util/http_methods.hpp diff --git a/include/fastmcpp/app.hpp b/include/fastmcpp/app.hpp index f3aa6cc..fcfb63b 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,8 @@ struct CustomRouteRequest std::string path; std::string body; std::unordered_map headers; + std::string target; + std::multimap query_params; }; /// HTTP response returned by a custom-route handler. @@ -49,7 +52,7 @@ struct CustomRouteResponse /// fixed forwarding from mounted servers). struct CustomRoute { - std::string method; // GET, POST, etc. (uppercase) + std::string method; // GET, POST, PUT, DELETE, PATCH std::string path; // Absolute path, e.g. "/health" — must start with '/' std::function handler; }; diff --git a/include/fastmcpp/providers/transforms/catalog.hpp b/include/fastmcpp/providers/transforms/catalog.hpp index 61babe8..2bf851b 100644 --- a/include/fastmcpp/providers/transforms/catalog.hpp +++ b/include/fastmcpp/providers/transforms/catalog.hpp @@ -108,7 +108,9 @@ class CatalogTransform : public Transform if (!entry.available_versions.empty()) { fastmcpp::Json meta = - entry.item.meta().has_value() ? *entry.item.meta() : fastmcpp::Json::object(); + entry.item.meta().has_value() && entry.item.meta()->is_object() + ? *entry.item.meta() + : fastmcpp::Json::object(); fastmcpp::Json fm = meta.contains("fastmcp") && meta["fastmcp"].is_object() ? meta["fastmcp"] : fastmcpp::Json::object(); diff --git a/include/fastmcpp/util/http_methods.hpp b/include/fastmcpp/util/http_methods.hpp new file mode 100644 index 0000000..20c8972 --- /dev/null +++ b/include/fastmcpp/util/http_methods.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "fastmcpp/exceptions.hpp" + +#include +#include +#include +#include + +namespace fastmcpp::util::http +{ + +inline std::string normalize_custom_route_method(std::string method) +{ + if (method.empty()) + throw ValidationError("CustomRoute.method is required"); + + std::transform(method.begin(), method.end(), method.begin(), + [](unsigned char ch) { return static_cast(std::toupper(ch)); }); + + static constexpr std::array kSupportedMethods = {"GET", "POST", "PUT", "DELETE", + "PATCH"}; + const auto supported = std::find(kSupportedMethods.begin(), kSupportedMethods.end(), method); + if (supported != kSupportedMethods.end()) + return method; + + if (method == "HEAD" || method == "OPTIONS") + throw ValidationError("CustomRoute.method '" + method + + "' is reserved and not supported for custom routes"); + + throw ValidationError("CustomRoute.method '" + method + + "' is unsupported; expected GET, POST, PUT, DELETE, or PATCH"); +} + +} // namespace fastmcpp::util::http diff --git a/src/app.cpp b/src/app.cpp index 1551868..a62731e 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6,6 +6,7 @@ #include "fastmcpp/mcp/handler.hpp" #include "fastmcpp/providers/provider.hpp" #include "fastmcpp/resources/template.hpp" +#include "fastmcpp/util/http_methods.hpp" #include "fastmcpp/util/json_schema.hpp" #include "fastmcpp/util/schema_build.hpp" @@ -287,11 +288,11 @@ FastMCP& FastMCP::add_custom_route(CustomRoute route) { if (route.path.empty() || route.path.front() != '/') throw ValidationError("CustomRoute.path must start with '/' (got '" + route.path + "')"); - if (route.method.empty()) - throw ValidationError("CustomRoute.method is required"); if (!route.handler) throw ValidationError("CustomRoute.handler is required"); + route.method = util::http::normalize_custom_route_method(std::move(route.method)); + // Re-registering the same (method, path) replaces the previous entry — // matches Python `@server.custom_route()` decorator semantics. for (auto& existing : custom_routes_) diff --git a/src/server/http_server.cpp b/src/server/http_server.cpp index 8c46d72..6ea66b0 100644 --- a/src/server/http_server.cpp +++ b/src/server/http_server.cpp @@ -2,6 +2,7 @@ #include "fastmcpp/app.hpp" #include "fastmcpp/exceptions.hpp" +#include "fastmcpp/util/http_methods.hpp" #include "fastmcpp/util/json.hpp" #include @@ -11,6 +12,14 @@ namespace fastmcpp::server void HttpServerWrapper::set_custom_routes(std::vector routes) { + for (auto& route : routes) + { + route.method = fastmcpp::util::http::normalize_custom_route_method(std::move(route.method)); + if (route.path.empty() || route.path.front() != '/') + throw ValidationError("CustomRoute.path must start with '/' (got '" + route.path + "')"); + if (!route.handler) + throw ValidationError("CustomRoute.handler is required"); + } custom_routes_ = std::move(routes); } @@ -107,9 +116,11 @@ bool HttpServerWrapper::start() apply_additional_response_headers(res); fastmcpp::CustomRouteRequest cr; - cr.method = route.method; + cr.method = req.method; cr.path = req.path; cr.body = req.body; + cr.target = req.target; + cr.query_params = req.params; for (const auto& [k, v] : req.headers) cr.headers[k] = v; try diff --git a/tests/app/custom_route_forwarding.cpp b/tests/app/custom_route_forwarding.cpp index 96c6bec..8505c96 100644 --- a/tests/app/custom_route_forwarding.cpp +++ b/tests/app/custom_route_forwarding.cpp @@ -65,9 +65,10 @@ static int test_register_replaces_duplicate() { std::cout << " test_register_replaces_duplicate..." << std::endl; FastMCP app("a", "1.0.0"); - app.add_custom_route(make_route("GET", "/x", "first")); + app.add_custom_route(make_route("get", "/x", "first")); app.add_custom_route(make_route("GET", "/x", "second")); ASSERT_EQ(app.custom_routes().size(), 1u, "still one route"); + ASSERT_EQ(app.custom_routes().front().method, std::string("GET"), "method normalized to uppercase"); auto resp = app.custom_routes().front().handler({"GET", "/x", "", {}}); ASSERT_EQ(resp.body, std::string("second"), "second handler wins"); std::cout << " PASS" << std::endl; @@ -89,6 +90,28 @@ static int test_validation_rejects_bad_inputs() } ASSERT_TRUE(threw, "missing leading slash rejected"); + threw = false; + try + { + app.add_custom_route(make_route("", "/x", "x")); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "missing method rejected"); + + threw = false; + try + { + app.add_custom_route(make_route("HEAD", "/x", "x")); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + ASSERT_TRUE(threw, "unsupported method rejected"); + threw = false; CustomRoute no_handler; no_handler.method = "GET"; @@ -106,6 +129,28 @@ static int test_validation_rejects_bad_inputs() return 0; } +static int test_http_wrapper_rejects_unsupported_custom_route_method() +{ + std::cout << " test_http_wrapper_rejects_unsupported_custom_route_method..." << std::endl; + + auto core = std::make_shared(); + server::HttpServerWrapper http(core, "127.0.0.1", 0); + + bool threw = false; + try + { + http.set_custom_routes({make_route("HEAD", "/health", "ok")}); + } + catch (const fastmcpp::ValidationError&) + { + threw = true; + } + + ASSERT_TRUE(threw, "direct wrapper route registration rejects unsupported methods"); + std::cout << " PASS" << std::endl; + return 0; +} + static int test_aggregate_from_mounted_child() { std::cout << " test_aggregate_from_mounted_child..." << std::endl; @@ -198,6 +243,93 @@ static int test_http_end_to_end_serves_route() return 0; } +static int test_http_custom_route_preserves_query_params() +{ + std::cout << " test_http_custom_route_preserves_query_params..." << std::endl; + + CustomRouteRequest captured; + bool called = false; + + FastMCP child("child", "1.0.0"); + CustomRoute query_route; + query_route.method = "GET"; + query_route.path = "/search"; + query_route.handler = [&](const CustomRouteRequest& req) + { + called = true; + captured = req; + + CustomRouteResponse resp; + resp.body = "query ok"; + resp.content_type = "text/plain"; + return resp; + }; + child.add_custom_route(std::move(query_route)); + + FastMCP parent("parent", "1.0.0"); + parent.mount(child, "kids"); + + auto core = std::make_shared(parent.server()); + + int port = 0; + std::unique_ptr http; + for (int candidate = 18481; candidate <= 18500; ++candidate) + { + auto trial = std::make_unique(core, "127.0.0.1", candidate); + trial->set_custom_routes(parent.all_custom_routes()); + if (trial->start()) + { + port = trial->port(); + http = std::move(trial); + break; + } + } + ASSERT_TRUE(http && port > 0, "HTTP server started"); + + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + + httplib::Client client("127.0.0.1", port); + client.set_connection_timeout(std::chrono::seconds(2)); + client.set_read_timeout(std::chrono::seconds(2)); + + auto resp = client.Get("/kids/search?q=a&q=b&lang=en"); + ASSERT_TRUE(resp != nullptr, "GET with query params returned a response"); + ASSERT_EQ(resp->status, 200, "query route served"); + ASSERT_EQ(resp->body, std::string("query ok"), "query route body"); + + ASSERT_TRUE(called, "handler was invoked"); + ASSERT_EQ(captured.method, std::string("GET"), "request method preserved"); + ASSERT_EQ(captured.path, std::string("/kids/search"), "path preserved without query string"); + ASSERT_EQ(captured.target, std::string("/kids/search?q=a&q=b&lang=en"), + "raw target preserves query string"); + ASSERT_EQ(captured.query_params.count("q"), 2u, "repeated query param preserved"); + ASSERT_EQ(captured.query_params.count("lang"), 1u, "single query param preserved"); + + auto q_range = captured.query_params.equal_range("q"); + bool seen_q_a = false; + bool seen_q_b = false; + size_t q_values = 0; + for (auto it = q_range.first; it != q_range.second; ++it) + { + ++q_values; + if (it->second == "a") + seen_q_a = true; + if (it->second == "b") + seen_q_b = true; + } + ASSERT_EQ(q_values, 2u, "two q values captured"); + ASSERT_TRUE(seen_q_a, "q=a preserved"); + ASSERT_TRUE(seen_q_b, "q=b preserved"); + + auto lang_it = captured.query_params.find("lang"); + ASSERT_TRUE(lang_it != captured.query_params.end(), "lang key present"); + ASSERT_EQ(lang_it->second, std::string("en"), "lang value preserved"); + + http->stop(); + std::cout << " PASS" << std::endl; + return 0; +} + static int test_http_custom_route_requires_auth() { std::cout << " test_http_custom_route_requires_auth..." << std::endl; @@ -296,9 +428,11 @@ int main() failures += test_register_basic(); failures += test_register_replaces_duplicate(); failures += test_validation_rejects_bad_inputs(); + failures += test_http_wrapper_rejects_unsupported_custom_route_method(); failures += test_aggregate_from_mounted_child(); failures += test_aggregate_dedups_collisions(); failures += test_http_end_to_end_serves_route(); + failures += test_http_custom_route_preserves_query_params(); failures += test_http_custom_route_requires_auth(); failures += test_http_custom_route_options_advertises_methods(); std::cout << std::endl; diff --git a/tests/providers/catalog_dedup.cpp b/tests/providers/catalog_dedup.cpp index 036a939..a1facaf 100644 --- a/tests/providers/catalog_dedup.cpp +++ b/tests/providers/catalog_dedup.cpp @@ -209,6 +209,71 @@ static int test_no_meta_for_single_version() return 0; } +static int test_get_tool_catalog_coerces_scalar_meta_to_object() +{ + std::cout << " test_get_tool_catalog_coerces_scalar_meta_to_object..." << std::endl; + NoopCatalogTransform t; + + auto v1 = make_tool("greet", "1"); + auto v3 = make_tool("greet", "3"); + v3.set_meta(Json("scalar-meta")); + + auto result = t.get_tool_catalog(fixed_call_next({v1, v3})); + ASSERT_EQ(result.size(), 1u, "one tool"); + ASSERT_TRUE(result[0].meta().has_value(), "meta present"); + ASSERT_TRUE(result[0].meta()->is_object(), "scalar meta coerced to object"); + ASSERT_TRUE((*result[0].meta()).contains("fastmcp"), "fastmcp block present"); + auto versions = (*result[0].meta())["fastmcp"]["versions"].get>(); + ASSERT_EQ(versions.size(), 2u, "two versions preserved"); + ASSERT_TRUE(versions[0] == "3" && versions[1] == "1", "version order preserved"); + + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_get_tool_catalog_coerces_array_meta_to_object() +{ + std::cout << " test_get_tool_catalog_coerces_array_meta_to_object..." << std::endl; + NoopCatalogTransform t; + + auto v1 = make_tool("greet", "1"); + auto v3 = make_tool("greet", "3"); + v3.set_meta(Json::array({"alpha", "beta"})); + + auto result = t.get_tool_catalog(fixed_call_next({v1, v3})); + ASSERT_EQ(result.size(), 1u, "one tool"); + ASSERT_TRUE(result[0].meta().has_value(), "meta present"); + ASSERT_TRUE(result[0].meta()->is_object(), "array meta coerced to object"); + ASSERT_TRUE((*result[0].meta()).contains("fastmcp"), "fastmcp block present"); + ASSERT_TRUE((*result[0].meta())["fastmcp"]["versions"].is_array(), "versions injected"); + + std::cout << " PASS" << std::endl; + return 0; +} + +static int test_get_tool_catalog_preserves_existing_object_meta() +{ + std::cout << " test_get_tool_catalog_preserves_existing_object_meta..." << std::endl; + NoopCatalogTransform t; + + auto v1 = make_tool("greet", "1"); + auto v3 = make_tool("greet", "3"); + v3.set_meta(Json{{"custom", "value"}, {"fastmcp", Json{{"source", "canonical"}}}}); + + auto result = t.get_tool_catalog(fixed_call_next({v1, v3})); + ASSERT_EQ(result.size(), 1u, "one tool"); + ASSERT_TRUE(result[0].meta().has_value(), "meta present"); + ASSERT_TRUE(result[0].meta()->is_object(), "object meta retained"); + ASSERT_TRUE((*result[0].meta()).value("custom", std::string{}) == "value", "custom key preserved"); + ASSERT_TRUE((*result[0].meta())["fastmcp"].value("source", std::string{}) == "canonical", + "existing fastmcp fields preserved"); + auto versions = (*result[0].meta())["fastmcp"]["versions"].get>(); + ASSERT_EQ(versions.size(), 2u, "versions still injected"); + + std::cout << " PASS" << std::endl; + return 0; +} + // 0142fefe: VersionFilter applied before CatalogTransform must restrict what // the catalog accessor sees. We simulate this by chaining a filter call_next. static int test_version_filter_applied_before_catalog() @@ -293,6 +358,41 @@ static int test_metadata_survives_tool_info_and_mcp_serialization() return 0; } +static int test_non_object_meta_does_not_break_tool_info_or_mcp_serialization() +{ + std::cout << " test_non_object_meta_does_not_break_tool_info_or_mcp_serialization..." << std::endl; + + auto v1 = make_tool("greet", "1"); + auto v3 = make_tool("greet", "3"); + v3.set_meta(Json("scalar-meta")); + + auto provider = std::make_shared(std::vector{v1, v3}); + provider->add_transform(std::make_shared()); + + FastMCP app("catalog", "1.0.0"); + app.add_provider(provider); + + auto tools = app.list_all_tools_info(); + ASSERT_EQ(tools.size(), 1u, "deduped tool list"); + ASSERT_TRUE(tools[0]._meta.has_value(), "tool info carries _meta"); + ASSERT_TRUE((*tools[0]._meta).is_object(), "non-object meta coerced before tool info serialization"); + ASSERT_TRUE((*tools[0]._meta).contains("fastmcp"), "fastmcp block present in tool info"); + ASSERT_TRUE((*tools[0]._meta)["fastmcp"].contains("versions"), "versions surfaced in tool info"); + + auto handler = mcp::make_mcp_handler(app); + Json req = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "tools/list"}}; + Json resp = handler(req); + ASSERT_TRUE(resp.contains("result") && resp["result"].contains("tools"), "tools/list result"); + ASSERT_EQ(resp["result"]["tools"].size(), 1u, "one serialized tool"); + const auto& tool = resp["result"]["tools"][0]; + ASSERT_TRUE(tool.contains("_meta") && tool["_meta"].is_object(), "serialized _meta"); + ASSERT_TRUE(tool["_meta"].contains("fastmcp"), "serialized fastmcp block"); + ASSERT_TRUE(tool["_meta"]["fastmcp"].contains("versions"), "serialized versions"); + + std::cout << " PASS" << std::endl; + return 0; +} + int main() { std::cout << "CatalogTransform Dedup + Ordering Tests" << std::endl; @@ -304,8 +404,12 @@ int main() failures += test_get_tool_catalog_injects_versions_meta(); failures += test_get_tool_catalog_mixed_versioned_unversioned(); failures += test_no_meta_for_single_version(); + failures += test_get_tool_catalog_coerces_scalar_meta_to_object(); + failures += test_get_tool_catalog_coerces_array_meta_to_object(); + failures += test_get_tool_catalog_preserves_existing_object_meta(); failures += test_version_filter_applied_before_catalog(); failures += test_metadata_survives_tool_info_and_mcp_serialization(); + failures += test_non_object_meta_does_not_break_tool_info_or_mcp_serialization(); std::cout << std::endl; if (failures == 0) { From 331ba494bf5ca7a96f56f538582f1dcaf07433f6 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sun, 19 Apr 2026 12:40:10 -0700 Subject: [PATCH 4/4] style: format custom route regression fixes --- src/server/http_server.cpp | 3 ++- tests/app/custom_route_forwarding.cpp | 3 ++- tests/providers/catalog_dedup.cpp | 12 ++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/server/http_server.cpp b/src/server/http_server.cpp index 6ea66b0..af9cc73 100644 --- a/src/server/http_server.cpp +++ b/src/server/http_server.cpp @@ -16,7 +16,8 @@ void HttpServerWrapper::set_custom_routes(std::vector rou { route.method = fastmcpp::util::http::normalize_custom_route_method(std::move(route.method)); if (route.path.empty() || route.path.front() != '/') - throw ValidationError("CustomRoute.path must start with '/' (got '" + route.path + "')"); + throw ValidationError("CustomRoute.path must start with '/' (got '" + route.path + + "')"); if (!route.handler) throw ValidationError("CustomRoute.handler is required"); } diff --git a/tests/app/custom_route_forwarding.cpp b/tests/app/custom_route_forwarding.cpp index 8505c96..4cbfa32 100644 --- a/tests/app/custom_route_forwarding.cpp +++ b/tests/app/custom_route_forwarding.cpp @@ -68,7 +68,8 @@ static int test_register_replaces_duplicate() app.add_custom_route(make_route("get", "/x", "first")); app.add_custom_route(make_route("GET", "/x", "second")); ASSERT_EQ(app.custom_routes().size(), 1u, "still one route"); - ASSERT_EQ(app.custom_routes().front().method, std::string("GET"), "method normalized to uppercase"); + ASSERT_EQ(app.custom_routes().front().method, std::string("GET"), + "method normalized to uppercase"); auto resp = app.custom_routes().front().handler({"GET", "/x", "", {}}); ASSERT_EQ(resp.body, std::string("second"), "second handler wins"); std::cout << " PASS" << std::endl; diff --git a/tests/providers/catalog_dedup.cpp b/tests/providers/catalog_dedup.cpp index a1facaf..b7dcb3b 100644 --- a/tests/providers/catalog_dedup.cpp +++ b/tests/providers/catalog_dedup.cpp @@ -264,7 +264,8 @@ static int test_get_tool_catalog_preserves_existing_object_meta() ASSERT_EQ(result.size(), 1u, "one tool"); ASSERT_TRUE(result[0].meta().has_value(), "meta present"); ASSERT_TRUE(result[0].meta()->is_object(), "object meta retained"); - ASSERT_TRUE((*result[0].meta()).value("custom", std::string{}) == "value", "custom key preserved"); + ASSERT_TRUE((*result[0].meta()).value("custom", std::string{}) == "value", + "custom key preserved"); ASSERT_TRUE((*result[0].meta())["fastmcp"].value("source", std::string{}) == "canonical", "existing fastmcp fields preserved"); auto versions = (*result[0].meta())["fastmcp"]["versions"].get>(); @@ -360,7 +361,8 @@ static int test_metadata_survives_tool_info_and_mcp_serialization() static int test_non_object_meta_does_not_break_tool_info_or_mcp_serialization() { - std::cout << " test_non_object_meta_does_not_break_tool_info_or_mcp_serialization..." << std::endl; + std::cout << " test_non_object_meta_does_not_break_tool_info_or_mcp_serialization..." + << std::endl; auto v1 = make_tool("greet", "1"); auto v3 = make_tool("greet", "3"); @@ -375,9 +377,11 @@ static int test_non_object_meta_does_not_break_tool_info_or_mcp_serialization() auto tools = app.list_all_tools_info(); ASSERT_EQ(tools.size(), 1u, "deduped tool list"); ASSERT_TRUE(tools[0]._meta.has_value(), "tool info carries _meta"); - ASSERT_TRUE((*tools[0]._meta).is_object(), "non-object meta coerced before tool info serialization"); + ASSERT_TRUE((*tools[0]._meta).is_object(), + "non-object meta coerced before tool info serialization"); ASSERT_TRUE((*tools[0]._meta).contains("fastmcp"), "fastmcp block present in tool info"); - ASSERT_TRUE((*tools[0]._meta)["fastmcp"].contains("versions"), "versions surfaced in tool info"); + ASSERT_TRUE((*tools[0]._meta)["fastmcp"].contains("versions"), + "versions surfaced in tool info"); auto handler = mcp::make_mcp_handler(app); Json req = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "tools/list"}};