diff --git a/CMakeLists.txt b/CMakeLists.txt index 14d6c63..65d589a 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.3.1 LANGUAGES CXX) +project(fastmcpp VERSION 3.4.2 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -267,6 +267,10 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_schema_dereference_toggle PRIVATE fastmcpp_core) add_test(NAME fastmcpp_schema_dereference_toggle COMMAND fastmcpp_schema_dereference_toggle) + add_executable(fastmcpp_schema_root_ref_metadata tests/schema/root_ref_metadata.cpp) + target_link_libraries(fastmcpp_schema_root_ref_metadata PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_schema_root_ref_metadata COMMAND fastmcpp_schema_root_ref_metadata) + add_executable(fastmcpp_content tests/content.cpp) target_link_libraries(fastmcpp_content PRIVATE fastmcpp_core) add_test(NAME fastmcpp_content COMMAND fastmcpp_content) @@ -339,6 +343,12 @@ if(FASTMCPP_BUILD_TESTS) add_test(NAME fastmcpp_resources_template_query_params COMMAND fastmcpp_resources_template_query_params) + add_executable(fastmcpp_resources_template_metadata + tests/resources/template_metadata.cpp) + target_link_libraries(fastmcpp_resources_template_metadata PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_resources_template_metadata + COMMAND fastmcpp_resources_template_metadata) + 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) @@ -451,6 +461,12 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_client_api_icons PRIVATE fastmcpp_core) add_test(NAME fastmcpp_client_api_icons COMMAND fastmcpp_client_api_icons) + add_executable(fastmcpp_client_tool_result_is_error + tests/client/tool_result_is_error.cpp) + target_link_libraries(fastmcpp_client_tool_result_is_error PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_client_tool_result_is_error + COMMAND fastmcpp_client_tool_result_is_error) + add_executable(fastmcpp_client_tasks tests/client/tasks.cpp) target_link_libraries(fastmcpp_client_tasks PRIVATE fastmcpp_core) add_test(NAME fastmcpp_client_tasks COMMAND fastmcpp_client_tasks) @@ -583,6 +599,10 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_proxy_basic PRIVATE fastmcpp_core) add_test(NAME fastmcpp_proxy_basic COMMAND fastmcpp_proxy_basic) + add_executable(fastmcpp_proxy_error_strategy tests/proxy/error_strategy.cpp) + target_link_libraries(fastmcpp_proxy_error_strategy PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_proxy_error_strategy COMMAND fastmcpp_proxy_error_strategy) + # Main header compile test add_executable(fastmcpp_main_header tests/main_header/compile_test.cpp) target_link_libraries(fastmcpp_main_header PRIVATE fastmcpp_core) diff --git a/README.md b/README.md index 5482dc4..e292b8e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ fastmcpp is a C++ port of the Python [fastmcp](https://github.com/jlowin/fastmcp **Status:** Beta – core MCP features track the Python `fastmcp` reference. -**Current version:** 3.3.1 +**Current version:** 3.4.2 ## Features diff --git a/include/fastmcpp/proxy.hpp b/include/fastmcpp/proxy.hpp index d18dc51..07fb49b 100644 --- a/include/fastmcpp/proxy.hpp +++ b/include/fastmcpp/proxy.hpp @@ -15,6 +15,47 @@ namespace fastmcpp { +/// Strategy for how `ProxyApp::list_all_*` aggregations handle upstream errors. +/// +/// Mirrors Python fastmcp v3.4.0 (#4227, commit 2bff3725): +/// `AggregateProvider.provider_error_strategy` and `FastMCPProxy.provider_error_strategy`. +/// +/// - `Warn`: log + return locally-known items (current default; pre-v3.4.2 behavior). +/// - `Raise`: re-raise the first upstream exception so callers can fail fast +/// (use this when the proxy must surface upstream failures, e.g. behind a +/// health check or for a bridge that should not pretend the upstream is +/// present). +enum class ProxyErrorStrategy +{ + Warn, + Raise, +}; + +/// Optional construction options for `ProxyApp` (v3.4.2 catch-up). +/// +/// Use this overload to opt into the v3.4.0+ behaviors: +/// - `error_strategy = Raise` — propagate upstream errors instead of +/// silently returning local-only. +/// - `validate_on_initialize = true` — contact the upstream during the +/// inbound MCP `initialize` and surface +/// any connection failure as an +/// `INTERNAL_ERROR` JSON-RPC response, +/// instead of failing later on the +/// first `tools/list` etc. Python +/// fastmcp v3.4.0+ does this always +/// (#4228, commit 140d96aa). fastmcpp +/// keeps it opt-in for back-compat +/// with proxies whose upstream is only +/// reachable some of the time. +struct ProxyAppOptions +{ + std::string name = "proxy_app"; + std::string version = "1.0.0"; + std::optional instructions; + ProxyErrorStrategy error_strategy = ProxyErrorStrategy::Warn; + bool validate_on_initialize = false; +}; + /// ProxyApp - An MCP server that proxies to a backend server /// /// This class creates an MCP server that forwards requests to a backend @@ -47,6 +88,24 @@ class ProxyApp std::string version = "1.0.0", std::optional instructions = std::nullopt); + /// Construct proxy with v3.4.2 options (F7 error strategy + F8 initialize + /// forwarding). Pre-existing constructors continue to default to the + /// previous behavior (`Warn`, no initialize forwarding) for back-compat. + explicit ProxyApp(ClientFactory client_factory, ProxyAppOptions options); + + /// Strategy applied when the upstream client throws during list_all_*. + ProxyErrorStrategy error_strategy() const + { + return error_strategy_; + } + + /// When true, the MCP `initialize` request triggers an upstream + /// connection check (used by `make_mcp_handler(const ProxyApp&)`). + bool validate_on_initialize() const + { + return validate_on_initialize_; + } + // Metadata accessors const std::string& name() const { @@ -142,6 +201,8 @@ class ProxyApp std::string name_; std::string version_; std::optional instructions_; + ProxyErrorStrategy error_strategy_{ProxyErrorStrategy::Warn}; + bool validate_on_initialize_{false}; tools::ToolManager local_tools_; resources::ResourceManager local_resources_; prompts::PromptManager local_prompts_; diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index 71f72e4..9ee3409 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -2581,6 +2581,25 @@ std::function make_mcp_handler(const Prox { fastmcpp::Json serverInfo = {{"name", app.name()}, {"version", app.version()}}; + // F8 (Python fastmcp #4228 / 140d96aa): when configured, + // forward the inbound MCP initialize to the upstream so an + // unreachable backend surfaces as a JSON-RPC error here + // instead of failing later on the first tools/list. fastmcpp + // keeps this opt-in via ProxyAppOptions::validate_on_initialize + // (Python made it always-on; we preserve back-compat). + if (app.validate_on_initialize()) + { + try + { + auto upstream = app.get_client(); + upstream.initialize(); + } + catch (const std::exception& e) + { + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); + } + } + // Advertise capabilities fastmcpp::Json capabilities = {{"tools", fastmcpp::Json::object()}}; if (!app.list_all_resources().empty() || !app.list_all_resource_templates().empty()) diff --git a/src/providers/transforms/version_filter.cpp b/src/providers/transforms/version_filter.cpp index 41966e9..fe0fd86 100644 --- a/src/providers/transforms/version_filter.cpp +++ b/src/providers/transforms/version_filter.cpp @@ -23,11 +23,23 @@ std::string strip_leading_zeros(const std::string& s) return s.substr(i); } +// Strip a leading 'v' or 'V' prefix when followed by a digit so versions +// like "v1.2" compare equal to "1.2". Mirrors Python `parse_version_key` +// behavior in fastmcp v3.4.0 (#4058 / commit 24b594b1). +std::string strip_v_prefix(const std::string& version) +{ + if (version.size() >= 2 && (version[0] == 'v' || version[0] == 'V') && + std::isdigit(static_cast(version[1]))) + return version.substr(1); + return version; +} + std::vector split_version(const std::string& version) { + const std::string normalized = strip_v_prefix(version); std::vector parts; std::string current; - for (char c : version) + for (char c : normalized) { if (c == '.' || c == '-' || c == '_') { diff --git a/src/proxy.cpp b/src/proxy.cpp index 2171e32..2b3a7ca 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -15,6 +15,14 @@ ProxyApp::ProxyApp(ClientFactory client_factory, std::string name, std::string v { } +ProxyApp::ProxyApp(ClientFactory client_factory, ProxyAppOptions options) + : client_factory_(std::move(client_factory)), name_(std::move(options.name)), + version_(std::move(options.version)), instructions_(std::move(options.instructions)), + error_strategy_(options.error_strategy), + validate_on_initialize_(options.validate_on_initialize) +{ +} + // ========================================================================= // Conversion Helpers // ========================================================================= @@ -121,10 +129,15 @@ std::vector ProxyApp::list_all_tools() const } catch (const std::exception& e) { - // Surface bad URL/transport; otherwise fall back to local-only + // Surface bad URL/transport; otherwise apply error strategy. if (dynamic_cast(&e)) throw; - // Remote not available, continue with local only + // F7 (Python fastmcp #4227 / 2bff3725): when configured to raise, + // propagate upstream failures so the caller can fail fast instead of + // silently degrading to local-only. + if (error_strategy_ == ProxyErrorStrategy::Raise) + throw; + // Default (Warn): remote not available, continue with local only. } return result; @@ -156,6 +169,8 @@ std::vector ProxyApp::list_all_resources() const { if (dynamic_cast(&e)) throw; + if (error_strategy_ == ProxyErrorStrategy::Raise) + throw; // Remote not available } @@ -188,6 +203,8 @@ std::vector ProxyApp::list_all_resource_templates() co { if (dynamic_cast(&e)) throw; + if (error_strategy_ == ProxyErrorStrategy::Raise) + throw; // Remote not available } @@ -220,6 +237,8 @@ std::vector ProxyApp::list_all_prompts() const { if (dynamic_cast(&e)) throw; + if (error_strategy_ == ProxyErrorStrategy::Raise) + throw; // Remote not available } diff --git a/src/resources/template.cpp b/src/resources/template.cpp index 4cc1c58..d7c5607 100644 --- a/src/resources/template.cpp +++ b/src/resources/template.cpp @@ -469,6 +469,15 @@ ResourceTemplate::create_resource(const std::string& uri, resource.mime_type = mime_type; resource.app = app; + // Mirror Python fastmcp #4061 (commit 01b971d8): preserve template-level + // metadata so resources instantiated from a template carry the template's + // display annotations rather than appearing unstyled/unannotated. + resource.title = title; + resource.annotations = annotations; + resource.icons = icons; + resource.version = version; + resource.task_support = task_support; + // Create a provider that captures the extracted params and delegates to the template provider if (provider) { diff --git a/tests/client/tool_result_is_error.cpp b/tests/client/tool_result_is_error.cpp new file mode 100644 index 0000000..4270ee5 --- /dev/null +++ b/tests/client/tool_result_is_error.cpp @@ -0,0 +1,112 @@ +// F5 — Python fastmcp #4217 (commit e242abee): ToolResult is_error +// +// The Python change introduced ToolResult.is_error so a tool can RETURN an +// error result (instead of raising). fastmcpp already supports this at the +// wire level: a server-side tool returning {"content": [...], "isError": true} +// surfaces through the JSON-RPC response, and the client's call_tool() with +// raise_on_error=false returns a CallToolResult with isError=true. +// +// This test pins that contract explicitly: +// - server returns an isError=true result with structured + text content +// - client.call_tool(name, args, ..., raise_on_error=false) MUST NOT throw +// - returned CallToolResult.isError == true +// - content + structuredContent are preserved (no collapse to "Tool call failed") +// +// Prior coverage (interactions_part1.cpp `test_call_tool_error_handling`) only +// validated the default raise_on_error=true (throw) path. + +#include "fastmcpp/client/client.hpp" +#include "fastmcpp/server/server.hpp" + +#include +#include +#include + +using namespace fastmcpp; + +namespace +{ +std::shared_ptr make_error_server() +{ + auto srv = std::make_shared(); + + srv->route( + "tools/list", + [](const Json&) + { + Json tools = Json::array(); + tools.push_back(Json{{"name", "always_error"}, + {"description", "Returns is_error=true with custom content"}, + {"inputSchema", Json{{"type", "object"}}}}); + return Json{{"tools", tools}}; + }); + + srv->route( + "tools/call", + [](const Json& in) + { + std::string name = in.at("name").get(); + if (name == "always_error") + { + // Server returns isError=true with structured + non-trivial content. + return Json{ + {"content", Json::array({Json{{"type", "text"}, + {"text", "validation failed: bad value"}}})}, + {"structuredContent", Json{{"errors", Json::array({"bad value"})}}}, + {"isError", true}}; + } + return Json{{"content", Json::array()}, {"isError", false}}; + }); + + return srv; +} +} // namespace + +int main() +{ + auto srv = make_error_server(); + + // 1) raise_on_error=true (default) MUST throw on isError=true. + { + client::Client c(std::make_unique(srv)); + bool threw = false; + try + { + c.call_tool("always_error", Json::object()); + } + catch (const fastmcpp::Error&) + { + threw = true; + } + assert(threw && "default raise_on_error must throw on isError=true"); + } + + // 2) F5: raise_on_error=false MUST return the result with isError=true. + { + client::Client c(std::make_unique(srv)); + auto result = + c.call_tool("always_error", Json::object(), std::nullopt, + std::chrono::milliseconds{0}, nullptr, /*raise_on_error=*/false); + assert(result.isError && "F5: isError must propagate when raise_on_error=false"); + + // 3) content preserved (NOT collapsed to a generic 'Tool call failed'). + assert(!result.content.empty() && "F5: content must be preserved"); + bool found_text = false; + for (const auto& block : result.content) + { + if (const auto* tc = std::get_if(&block)) + { + if (tc->text.find("validation failed") != std::string::npos) + found_text = true; + } + } + assert(found_text && "F5: original error text must be preserved"); + + // 4) structuredContent preserved too. + assert(result.structuredContent.has_value() && "F5: structuredContent preserved"); + assert(result.structuredContent->contains("errors") && "F5: structured errors preserved"); + } + + std::cout << "PASS: ToolResult is_error wire-level passthrough (F5)" << std::endl; + return 0; +} diff --git a/tests/providers/version_filter.cpp b/tests/providers/version_filter.cpp index 9b46e74..cfdff1c 100644 --- a/tests/providers/version_filter.cpp +++ b/tests/providers/version_filter.cpp @@ -76,6 +76,10 @@ int main() provider->add_tool(make_tool("legacy_tool", "1.9.0", 1)); provider->add_tool(make_tool("v2_tool", "2.3.0", 2)); provider->add_tool(make_tool("no_version_tool", "", 3)); + // F1 (Python fastmcp #4058 / 24b594b1): a leading `v` prefix on a version + // string must compare equal to the same number without the prefix. + // `v2.5.0` therefore falls into the [2.0, 3.0) range below. + provider->add_tool(make_tool("v_prefixed_tool", "v2.5.0", 4)); provider->add_resource(make_resource("res://legacy", "1.0")); provider->add_resource(make_resource("res://v2", "2.0")); provider->add_template(make_template("res://legacy/{id}", "1.0")); @@ -91,21 +95,27 @@ int main() std::vector tools; for (const auto& info : app.list_all_tools_info()) tools.push_back(info.name); - assert(tools.size() == 2); + assert(tools.size() == 3); // v2_tool, v_prefixed_tool, no_version_tool bool saw_v2 = false; + bool saw_v_prefixed = false; bool saw_unversioned = false; for (const auto& tool_name : tools) { if (tool_name == "v2_tool") saw_v2 = true; + if (tool_name == "v_prefixed_tool") + saw_v_prefixed = true; if (tool_name == "no_version_tool") saw_unversioned = true; } assert(saw_v2); + assert(saw_v_prefixed); assert(saw_unversioned); auto result = app.invoke_tool("v2_tool", Json::object()); assert(result == 2); + auto v_prefixed = app.invoke_tool("v_prefixed_tool", Json::object()); + assert(v_prefixed == 4); auto no_version = app.invoke_tool("no_version_tool", Json::object()); assert(no_version == 3); try diff --git a/tests/proxy/error_strategy.cpp b/tests/proxy/error_strategy.cpp new file mode 100644 index 0000000..5a008d6 --- /dev/null +++ b/tests/proxy/error_strategy.cpp @@ -0,0 +1,169 @@ +// F7 + F8 — Proxy upstream error strategy + initialize forwarding +// +// F7 (Python fastmcp #4227 / 2bff3725): ProxyErrorStrategy controls whether +// list_all_* silently degrades on upstream errors (Warn, default) or propagates +// them (Raise). +// +// F8 (Python fastmcp #4228 / 140d96aa): when ProxyAppOptions::validate_on_initialize +// is true, the proxy's MCP initialize handler contacts the upstream and surfaces +// failures as a JSON-RPC INTERNAL_ERROR response. Python made this always-on in +// v3.4.0; fastmcpp keeps it opt-in for back-compat. + +#include "fastmcpp/client/client.hpp" +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/mcp/handler.hpp" +#include "fastmcpp/proxy.hpp" + +#include +#include +#include +#include +#include + +using namespace fastmcpp; + +namespace +{ +// A transport that throws on every request — simulates an unreachable upstream. +class UnreachableTransport : public client::ITransport +{ + public: + Json request(const std::string&, const Json&) override + { + throw fastmcpp::Error("upstream unreachable (simulated)"); + } +}; + +ProxyApp::ClientFactory unreachable_factory() +{ + return []() { return client::Client(std::make_unique()); }; +} +} // namespace + +void test_default_warn_swallows_upstream_failure() +{ + std::cout << "Test: default (Warn) swallows upstream failure...\n"; + ProxyApp proxy(unreachable_factory(), "p1", "1.0.0"); + + // Default strategy is Warn: list_all_tools should return empty rather than throw. + auto tools = proxy.list_all_tools(); + assert(tools.empty()); + + auto resources = proxy.list_all_resources(); + assert(resources.empty()); + + auto prompts = proxy.list_all_prompts(); + assert(prompts.empty()); + + auto templates = proxy.list_all_resource_templates(); + assert(templates.empty()); + + std::cout << " [PASS]\n"; +} + +void test_raise_propagates_upstream_failure() +{ + std::cout << "Test: Raise propagates upstream failure (F7)...\n"; + ProxyAppOptions opts; + opts.name = "p_raise"; + opts.error_strategy = ProxyErrorStrategy::Raise; + ProxyApp proxy(unreachable_factory(), opts); + + bool threw_tools = false; + try + { + (void)proxy.list_all_tools(); + } + catch (const std::exception&) + { + threw_tools = true; + } + assert(threw_tools && "F7: list_all_tools must throw under Raise"); + + bool threw_resources = false; + try + { + (void)proxy.list_all_resources(); + } + catch (const std::exception&) + { + threw_resources = true; + } + assert(threw_resources && "F7: list_all_resources must throw under Raise"); + + bool threw_prompts = false; + try + { + (void)proxy.list_all_prompts(); + } + catch (const std::exception&) + { + threw_prompts = true; + } + assert(threw_prompts && "F7: list_all_prompts must throw under Raise"); + + bool threw_templates = false; + try + { + (void)proxy.list_all_resource_templates(); + } + catch (const std::exception&) + { + threw_templates = true; + } + assert(threw_templates && "F7: list_all_resource_templates must throw under Raise"); + + std::cout << " [PASS]\n"; +} + +void test_initialize_without_validation_succeeds_when_upstream_down() +{ + std::cout << "Test: MCP initialize succeeds without validate_on_initialize...\n"; + // Default options: validate_on_initialize=false. The proxy's initialize + // handler must NOT contact the upstream — it returns its own serverInfo. + ProxyApp proxy(unreachable_factory(), "p2", "1.0.0"); + auto handler = mcp::make_mcp_handler(proxy); + + Json req = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "initialize"}, + {"params", Json::object()}, + }; + Json resp = handler(req); + assert(resp.contains("result") && "default: initialize returns result even when upstream down"); + assert(resp["result"]["serverInfo"]["name"] == "p2"); + std::cout << " [PASS]\n"; +} + +void test_initialize_with_validation_surfaces_upstream_failure() +{ + std::cout << "Test: MCP initialize surfaces upstream failure under validate_on_initialize (F8)...\n"; + ProxyAppOptions opts; + opts.name = "p3"; + opts.validate_on_initialize = true; + ProxyApp proxy(unreachable_factory(), opts); + auto handler = mcp::make_mcp_handler(proxy); + + Json req = { + {"jsonrpc", "2.0"}, + {"id", 7}, + {"method", "initialize"}, + {"params", Json::object()}, + }; + Json resp = handler(req); + assert(resp.contains("error") && "F8: initialize must error out when upstream unreachable"); + assert(resp["error"].value("code", 0) == -32603 && + "F8: error code must be INTERNAL_ERROR (-32603)"); + std::cout << " [PASS]\n"; +} + +int main() +{ + test_default_warn_swallows_upstream_failure(); + test_raise_propagates_upstream_failure(); + test_initialize_without_validation_succeeds_when_upstream_down(); + test_initialize_with_validation_surfaces_upstream_failure(); + std::cout << "PASS: proxy error strategy + initialize forwarding (F7 + F8)" << std::endl; + return 0; +} diff --git a/tests/resources/template_metadata.cpp b/tests/resources/template_metadata.cpp new file mode 100644 index 0000000..9c81ca3 --- /dev/null +++ b/tests/resources/template_metadata.cpp @@ -0,0 +1,99 @@ +// Resource template metadata propagation tests +// F2 — Python fastmcp #4061 (commit 01b971d8): when instantiating a Resource +// from a ResourceTemplate, the template-level title/annotations/icons/version +// must propagate to the resulting Resource instead of being dropped. + +#include "fastmcpp/resources/template.hpp" + +#include "fastmcpp/resources/resource.hpp" +#include "fastmcpp/types.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) + +int main() +{ + ResourceTemplate templ; + templ.uri_template = "data://{kind}/{id}"; + templ.name = "data_template"; + templ.description = "A data resource template"; + templ.mime_type = "application/json"; + templ.version = "1.2.3"; + + templ.title = std::string("My Data Template"); + + Json ann = Json::object(); + ann["audience"] = Json::array({"user"}); + ann["priority"] = 0.9; + ann["lastModified"] = "2026-06-09T00:00:00Z"; + templ.annotations = ann; + + std::vector icons; + Icon icon; + icon.src = "https://example.com/icon.png"; + icon.mime_type = std::string("image/png"); + icons.push_back(icon); + templ.icons = icons; + + templ.task_support = TaskSupport::Optional; + templ.parameters = Json::object(); + templ.provider = [](const Json&) + { + ResourceContent content; + content.uri = "data://x/1"; + content.data = std::string("{\"ok\":true}"); + return content; + }; + templ.parse(); + + std::unordered_map params = {{"kind", "user"}, {"id", "42"}}; + Resource resource = templ.create_resource("data://user/42", params); + + // Pre-existing fields still propagate + ASSERT_TRUE(resource.uri == "data://user/42", "uri propagated"); + ASSERT_TRUE(resource.name == "data_template", "name propagated"); + ASSERT_TRUE(resource.description.has_value(), "description present"); + ASSERT_TRUE(*resource.description == "A data resource template", "description propagated"); + ASSERT_TRUE(resource.mime_type.has_value() && *resource.mime_type == "application/json", + "mime_type propagated"); + + // F2: new fields must now propagate + ASSERT_TRUE(resource.title.has_value(), "title present (F2)"); + ASSERT_TRUE(*resource.title == "My Data Template", "title value preserved (F2)"); + + ASSERT_TRUE(resource.annotations.has_value(), "annotations present (F2)"); + ASSERT_TRUE(resource.annotations->contains("audience"), "annotations.audience preserved (F2)"); + ASSERT_TRUE(resource.annotations->at("priority") == 0.9, "annotations.priority preserved (F2)"); + + ASSERT_TRUE(resource.icons.has_value(), "icons present (F2)"); + ASSERT_TRUE(resource.icons->size() == 1, "icons count (F2)"); + ASSERT_TRUE(resource.icons->at(0).src == "https://example.com/icon.png", + "icon src preserved (F2)"); + + ASSERT_TRUE(resource.version.has_value(), "version present (F2)"); + ASSERT_TRUE(*resource.version == "1.2.3", "version preserved (F2)"); + + ASSERT_TRUE(resource.task_support == TaskSupport::Optional, "task_support preserved (F2)"); + + // Provider still callable + Json extra = Json::object(); + auto content = resource.provider(extra); + ASSERT_TRUE(content.uri == "data://x/1", "provider still callable"); + + std::cout << "PASS: template metadata propagation (F2)" << std::endl; + return 0; +} diff --git a/tests/schema/root_ref_metadata.cpp b/tests/schema/root_ref_metadata.cpp new file mode 100644 index 0000000..775a652 --- /dev/null +++ b/tests/schema/root_ref_metadata.cpp @@ -0,0 +1,79 @@ +// JSON-schema root-ref metadata preservation +// F4 — Python fastmcp #4178 (commit 834f96d4, refactored cosmetically in #4023): +// When a schema's root is `{"$ref": "#/$defs/X", "title": "T", "description": "D"}`, +// dereferencing must preserve the sibling metadata (title/description/default/examples) +// from the root onto the resolved schema. +// +// This is a verify-first test: fastmcpp's `dereference_node` already implements +// the sibling-merge pattern. Asserting it explicitly future-proofs against +// regressions that would drop the metadata. + +#include "fastmcpp/util/json_schema.hpp" + +#include +#include + +using namespace fastmcpp; + +int main() +{ + // Root is a $ref with sibling metadata. After dereferencing we must see + // the resolved schema content AND the sibling title/description. + Json schema = { + {"$ref", "#/$defs/Person"}, + {"title", "PersonRoot"}, + {"description", "A person at the root"}, + {"$defs", Json{{"Person", Json{ + {"type", "object"}, + {"properties", Json{{"name", Json{{"type", "string"}}}}}, + }}}}, + }; + + Json deref = util::schema::dereference_refs(schema); + + if (!deref.is_object()) + { + std::cerr << "FAIL: expected object after deref" << std::endl; + return 1; + } + + // Resolved Person content + if (deref.value("type", std::string()) != "object") + { + std::cerr << "FAIL: resolved type missing/wrong: " << deref.dump() << std::endl; + return 1; + } + if (!deref.contains("properties") || !deref["properties"].is_object() || + !deref["properties"].contains("name")) + { + std::cerr << "FAIL: properties.name missing: " << deref.dump() << std::endl; + return 1; + } + + // Root sibling metadata preserved + if (deref.value("title", std::string()) != "PersonRoot") + { + std::cerr << "FAIL: root title not preserved: " << deref.dump() << std::endl; + return 1; + } + if (deref.value("description", std::string()) != "A person at the root") + { + std::cerr << "FAIL: root description not preserved: " << deref.dump() << std::endl; + return 1; + } + + // $ref must be gone, and $defs cleared when no remaining refs + if (deref.contains("$ref")) + { + std::cerr << "FAIL: $ref still present: " << deref.dump() << std::endl; + return 1; + } + if (deref.contains("$defs")) + { + std::cerr << "FAIL: $defs not stripped after full deref: " << deref.dump() << std::endl; + return 1; + } + + std::cout << "PASS: root-ref metadata preservation (F4)" << std::endl; + return 0; +}