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/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 d29f028..fcfb63b 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -8,7 +8,9 @@ #include "fastmcpp/tools/manager.hpp" #include +#include #include +#include #include #include #include @@ -23,6 +25,38 @@ 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; + std::string target; + std::multimap query_params; +}; + +/// 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, PUT, DELETE, PATCH + 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 +310,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 +379,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/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 55337ad..2bf851b 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 @@ -60,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(); } @@ -78,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(); } @@ -87,21 +86,50 @@ 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()->is_object() + ? *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 - 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/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..ace59d1 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 + 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..a277c39 100644 --- a/include/fastmcpp/server/http_server.hpp +++ b/include/fastmcpp/server/http_server.hpp @@ -2,15 +2,22 @@ #include "fastmcpp/server/server.hpp" #include +#include #include #include #include #include +#include namespace httplib { class Server; class Response; +} // namespace httplib + +namespace fastmcpp +{ +struct CustomRoute; } namespace fastmcpp::server @@ -31,11 +38,16 @@ 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(); + /// 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 +81,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/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.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..99b21d8 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,32 @@ 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/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/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..b28f8e7 --- /dev/null +++ b/include/fastmcpp/util/versions.hpp @@ -0,0 +1,176 @@ +#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..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" @@ -283,6 +284,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.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_) + { + 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 // ========================================================================= @@ -488,10 +554,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); @@ -931,9 +1001,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/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 7002116..b07bada 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,89 @@ 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 +303,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,10 +347,31 @@ 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> ResourceTemplate::match(const std::string& uri) const { diff --git a/src/server/http_server.cpp b/src/server/http_server.cpp index a7ee735..af9cc73 100644 --- a/src/server/http_server.cpp +++ b/src/server/http_server.cpp @@ -1,6 +1,8 @@ #include "fastmcpp/server/http_server.hpp" +#include "fastmcpp/app.hpp" #include "fastmcpp/exceptions.hpp" +#include "fastmcpp/util/http_methods.hpp" #include "fastmcpp/util/json.hpp" #include @@ -8,6 +10,20 @@ 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); +} + HttpServerWrapper::HttpServerWrapper(std::shared_ptr core, std::string host, int port, std::string auth_token, std::string cors_origin, std::unordered_map response_headers) @@ -62,28 +78,86 @@ 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 + // mounted children, see FastMCP::all_custom_routes()). + for (const auto& route : custom_routes_) + { + if (!route.handler) + continue; + 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 = 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 + { + 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) + [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 new file mode 100644 index 0000000..4cbfa32 --- /dev/null +++ b/tests/app/custom_route_forwarding.cpp @@ -0,0 +1,447 @@ +// 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"); + 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; + 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; + 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"; + 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_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; + 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; +} + +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; + 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; + std::cout << "=============================" << std::endl; + int failures = 0; + 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; + 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/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 90ca409..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}}; }); @@ -43,10 +42,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 = { @@ -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 new file mode 100644 index 0000000..b7dcb3b --- /dev/null +++ b/tests/providers/catalog_dedup.cpp @@ -0,0 +1,425 @@ +// 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/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" + +#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 +{ +}; + +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) +{ + 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; +} + +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() +{ + 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; +} + +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; +} + +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; + 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_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) + { + std::cout << "All tests PASSED!" << std::endl; + return 0; + } + std::cout << failures << " test(s) FAILED" << std::endl; + return 1; +} 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 new file mode 100644 index 0000000..4760513 --- /dev/null +++ b/tests/resources/template_query_params.cpp @@ -0,0 +1,307 @@ +// 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/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 1dda79c..491d7d4 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; +}