Skip to content

Commit 014b715

Browse files
committed
feat(parity): fastmcpp 3.1.1 post-v3.1.0 parity sweep (F1-F9)
Squash of feature/parity-v3.1.0-post-sync (commit 6f5406c) onto main. Implements the seven parity gaps identified in the post-v3.1.0 review against Python fastmcp v3.1.0-89-g00ed31f2: - F1 typed query-param coercion on resource templates (mirrors Python 9ccaef2b); ValidationError on invalid bool/int/number. - F2 defensive read_fastmcp_metadata helper for _meta.fastmcp / _meta._fastmcp (mirrors 706b56d5). Not yet wired into any production path. - F3 URI-template regex guard rethrowing as ValidationError (hardening on top of 5ff64ce2). Runtime path currently unreachable via any template string given escape_regex() design. - F4 CatalogTransform::get_tool_catalog dedup with _meta.fastmcp.versions injection + Tool::meta()/set_meta() (mirrors 03673d9f + 0142fefe). New util/versions.hpp with dedupe_with_versions<T>(). - F5 preflight rename-collision detection in build_transformed_schema (mirrors d316f193). - F6 mount + query-params: N/A under fastmcpp's direct-dispatch mount, locked by mount_query_params.cpp regression test. - F7 FastMCP::add_custom_route / all_custom_routes + HttpServerWrapper::set_custom_routes (mirrors 68e76fea; ports the @server.custom_route API that didn't previously exist in fastmcpp). - F8 manual-redirect policy comment in StreamableHttpTransport (mirrors 226bfb49). - F9 version bump 3.1.0 -> 3.1.1. Bonus: fixes pre-existing assertion bug in tests/mcp/server_handler.cpp where the tools/list size assertion lagged behind the audio_tool addition in upstream a817ecf. Verification: - Submodule ctest Release suite: 101/103 passing (2 pre-existing flakes fail identically on clean 9afa99f: fastmcpp_streaming_sse and fastmcpp_example_streaming_demo). - Private monorepo interop (parent ports repo): 20/20 across F1/F2/F3/F4/F5/F7; stable over 5 back-to-back runs. See kb/sync/review_of_parity_branch.md in the parent ports repo for the full independent review.
1 parent 0df4ff5 commit 014b715

21 files changed

Lines changed: 1837 additions & 14 deletions

CMakeLists.txt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.16)
33
if(POLICY CMP0169)
44
cmake_policy(SET CMP0169 OLD)
55
endif()
6-
project(fastmcpp VERSION 3.1.0 LANGUAGES CXX)
6+
project(fastmcpp VERSION 3.1.1 LANGUAGES CXX)
77

88
set(CMAKE_CXX_STANDARD 17)
99
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -284,6 +284,10 @@ if(FASTMCPP_BUILD_TESTS)
284284
target_link_libraries(fastmcpp_settings PRIVATE fastmcpp_core)
285285
add_test(NAME fastmcpp_settings COMMAND fastmcpp_settings)
286286

287+
add_executable(fastmcpp_util_metadata_parsing tests/util/metadata_parsing.cpp)
288+
target_link_libraries(fastmcpp_util_metadata_parsing PRIVATE fastmcpp_core)
289+
add_test(NAME fastmcpp_util_metadata_parsing COMMAND fastmcpp_util_metadata_parsing)
290+
287291
add_executable(fastmcpp_stdio_server tests/transports/stdio_server.cpp)
288292
target_link_libraries(fastmcpp_stdio_server PRIVATE fastmcpp_core)
289293
add_test(NAME fastmcpp_stdio_server COMMAND fastmcpp_stdio_server)
@@ -318,6 +322,12 @@ if(FASTMCPP_BUILD_TESTS)
318322
target_link_libraries(fastmcpp_resources_templates PRIVATE fastmcpp_core)
319323
add_test(NAME fastmcpp_resources_templates COMMAND fastmcpp_resources_templates)
320324

325+
add_executable(fastmcpp_resources_template_query_params
326+
tests/resources/template_query_params.cpp)
327+
target_link_libraries(fastmcpp_resources_template_query_params PRIVATE fastmcpp_core)
328+
add_test(NAME fastmcpp_resources_template_query_params
329+
COMMAND fastmcpp_resources_template_query_params)
330+
321331
add_executable(fastmcpp_server_basic tests/server/basic.cpp)
322332
target_link_libraries(fastmcpp_server_basic PRIVATE fastmcpp_core)
323333
add_test(NAME fastmcpp_server_basic COMMAND fastmcpp_server_basic)
@@ -490,6 +500,15 @@ if(FASTMCPP_BUILD_TESTS)
490500
set_tests_properties(fastmcpp_stdio_timeout PROPERTIES TIMEOUT 60)
491501

492502
# App mounting tests
503+
add_executable(fastmcpp_app_mount_query_params tests/app/mount_query_params.cpp)
504+
target_link_libraries(fastmcpp_app_mount_query_params PRIVATE fastmcpp_core)
505+
add_test(NAME fastmcpp_app_mount_query_params COMMAND fastmcpp_app_mount_query_params)
506+
507+
add_executable(fastmcpp_app_custom_route_forwarding
508+
tests/app/custom_route_forwarding.cpp)
509+
target_link_libraries(fastmcpp_app_custom_route_forwarding PRIVATE fastmcpp_core)
510+
add_test(NAME fastmcpp_app_custom_route_forwarding COMMAND fastmcpp_app_custom_route_forwarding)
511+
493512
add_executable(fastmcpp_app_mounting tests/app/mounting.cpp)
494513
target_link_libraries(fastmcpp_app_mounting PRIVATE fastmcpp_core)
495514
add_test(NAME fastmcpp_app_mounting COMMAND fastmcpp_app_mounting)
@@ -528,6 +547,10 @@ if(FASTMCPP_BUILD_TESTS)
528547
target_link_libraries(fastmcpp_provider_version_filter PRIVATE fastmcpp_core)
529548
add_test(NAME fastmcpp_provider_version_filter COMMAND fastmcpp_provider_version_filter)
530549

550+
add_executable(fastmcpp_provider_catalog_dedup tests/providers/catalog_dedup.cpp)
551+
target_link_libraries(fastmcpp_provider_catalog_dedup PRIVATE fastmcpp_core)
552+
add_test(NAME fastmcpp_provider_catalog_dedup COMMAND fastmcpp_provider_catalog_dedup)
553+
531554
add_executable(fastmcpp_provider_catalog_search tests/providers/test_catalog_search_transforms.cpp)
532555
target_link_libraries(fastmcpp_provider_catalog_search PRIVATE fastmcpp_core)
533556
add_test(NAME fastmcpp_provider_catalog_search COMMAND fastmcpp_provider_catalog_search)

include/fastmcpp/app.hpp

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include "fastmcpp/tools/manager.hpp"
99

1010
#include <chrono>
11+
#include <functional>
1112
#include <initializer_list>
1213
#include <memory>
1314
#include <optional>
@@ -23,6 +24,36 @@ namespace providers
2324
class Provider;
2425
} // namespace providers
2526

27+
/// HTTP request snapshot passed to a custom-route handler.
28+
/// Kept transport-agnostic so HttpServerWrapper can populate it from
29+
/// cpp-httplib without leaking that dependency into app.hpp.
30+
struct CustomRouteRequest
31+
{
32+
std::string method;
33+
std::string path;
34+
std::string body;
35+
std::unordered_map<std::string, std::string> headers;
36+
};
37+
38+
/// HTTP response returned by a custom-route handler.
39+
struct CustomRouteResponse
40+
{
41+
int status{200};
42+
std::string body;
43+
std::string content_type{"text/plain"};
44+
std::unordered_map<std::string, std::string> headers;
45+
};
46+
47+
/// User-registered HTTP endpoint outside the JSON-RPC core.
48+
/// Parity intent with Python fastmcp `@server.custom_route()` (commit 68e76fea
49+
/// fixed forwarding from mounted servers).
50+
struct CustomRoute
51+
{
52+
std::string method; // GET, POST, etc. (uppercase)
53+
std::string path; // Absolute path, e.g. "/health" — must start with '/'
54+
std::function<CustomRouteResponse(const CustomRouteRequest&)> handler;
55+
};
56+
2657
/// Mounted app reference with prefix (direct mode)
2758
struct MountedApp
2859
{
@@ -276,6 +307,23 @@ class FastMCP
276307
return proxy_mounted_;
277308
}
278309

310+
/// Register a custom HTTP route handled outside the JSON-RPC core.
311+
/// Parity with Python fastmcp `@server.custom_route()`. Re-registering the
312+
/// same (method, path) replaces the previous handler.
313+
FastMCP& add_custom_route(CustomRoute route);
314+
315+
/// Get this app's directly registered custom routes (no mount prefixes).
316+
const std::vector<CustomRoute>& custom_routes() const
317+
{
318+
return custom_routes_;
319+
}
320+
321+
/// Aggregate this app's custom routes plus those of every directly mounted
322+
/// child app (paths are prefixed with the mount prefix). Parity with Python
323+
/// fastmcp commit 68e76fea (forward custom_route endpoints from mounted
324+
/// servers).
325+
std::vector<CustomRoute> all_custom_routes() const;
326+
279327
void add_provider(std::shared_ptr<providers::Provider> provider);
280328
const std::vector<std::shared_ptr<providers::Provider>>& providers() const
281329
{
@@ -328,6 +376,7 @@ class FastMCP
328376
std::vector<std::shared_ptr<providers::Provider>> providers_;
329377
std::vector<MountedApp> mounted_;
330378
std::vector<ProxyMountedApp> proxy_mounted_;
379+
std::vector<CustomRoute> custom_routes_;
331380
mutable std::vector<tools::Tool> provider_tools_cache_;
332381
mutable std::vector<prompts::Prompt> provider_prompts_cache_;
333382
int list_page_size_{0};

include/fastmcpp/providers/transforms/catalog.hpp

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#pragma once
22

33
#include "fastmcpp/providers/transforms/transform.hpp"
4+
#include "fastmcpp/util/versions.hpp"
45

56
#include <atomic>
67

@@ -87,10 +88,39 @@ class CatalogTransform : public Transform
8788
// ---- Catalog accessors (bypass this transform) ----
8889

8990
/// Fetch the real tool catalog, bypassing this transform's transform_tools.
91+
///
92+
/// Tools sharing a name are deduplicated by version: only the highest
93+
/// version survives. When more than one concrete version was present,
94+
/// the surviving Tool's `meta()` is augmented with
95+
/// `{"fastmcp": {"versions": [...]}}` listing all available versions in
96+
/// descending order. Parity with Python fastmcp commit 03673d9f.
9097
std::vector<tools::Tool> get_tool_catalog(const ListToolsNext& call_next) const
9198
{
9299
BypassGuard guard(bypass_);
93-
return call_next();
100+
auto raw = call_next();
101+
102+
auto deduped = util::versions::dedupe_with_versions(
103+
raw, [](const tools::Tool& t) { return t.name(); },
104+
[](const tools::Tool& t) { return t.version(); });
105+
106+
std::vector<tools::Tool> result;
107+
result.reserve(deduped.size());
108+
for (auto& entry : deduped)
109+
{
110+
if (!entry.available_versions.empty())
111+
{
112+
fastmcpp::Json meta =
113+
entry.item.meta().has_value() ? *entry.item.meta() : fastmcpp::Json::object();
114+
fastmcpp::Json fm = meta.contains("fastmcp") && meta["fastmcp"].is_object()
115+
? meta["fastmcp"]
116+
: fastmcpp::Json::object();
117+
fm["versions"] = entry.available_versions;
118+
meta["fastmcp"] = std::move(fm);
119+
entry.item.set_meta(std::move(meta));
120+
}
121+
result.push_back(std::move(entry.item));
122+
}
123+
return result;
94124
}
95125

96126
std::vector<resources::Resource>

include/fastmcpp/resources/manager.hpp

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,11 @@ class ResourceManager
6868
auto match_params = templ.match(uri);
6969
if (match_params)
7070
{
71-
// Merge explicit params with matched params (explicit takes precedence)
72-
Json merged_params = Json::object();
73-
for (const auto& [key, value] : *match_params)
74-
merged_params[key] = value;
71+
// Merge explicit params with matched params (explicit takes precedence).
72+
// Matched values are string-typed; coerce them per-param against the
73+
// template's parameter schema. Parity with Python fastmcp 9ccaef2b:
74+
// invalid booleans / numbers raise ValidationError.
75+
Json merged_params = templ.build_typed_params(*match_params);
7576
for (const auto& [key, value] : params.items())
7677
merged_params[key] = value;
7778

include/fastmcpp/resources/template.hpp

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,26 @@
1212
namespace fastmcpp::resources
1313
{
1414

15+
/// Type annotation for a URI template parameter.
16+
///
17+
/// When a parameter's kind is anything other than String, matched values go
18+
/// through typed coercion (see build_typed_params()). Invalid literals raise
19+
/// fastmcpp::ValidationError — parity with Python fastmcp commit 9ccaef2b.
20+
enum class ParamKind
21+
{
22+
String,
23+
Integer,
24+
Number,
25+
Boolean
26+
};
27+
1528
/// Parameter extracted from URI template
1629
struct TemplateParameter
1730
{
1831
std::string name;
19-
bool is_wildcard{false}; // {var*} vs {var}
20-
bool is_query{false}; // {?var} query param
32+
bool is_wildcard{false}; // {var*} vs {var}
33+
bool is_query{false}; // {?var} query param
34+
ParamKind kind{ParamKind::String};
2135
};
2236

2337
/// MCP Resource Template definition
@@ -56,6 +70,12 @@ struct ResourceTemplate
5670
/// Create a resource from the template with given parameters
5771
Resource create_resource(const std::string& uri,
5872
const std::unordered_map<std::string, std::string>& params) const;
73+
74+
/// Build a typed JSON object from a raw string -> string parameter map,
75+
/// coercing each value using the per-parameter kind populated by parse().
76+
/// Parity with Python fastmcp commit 9ccaef2b — invalid booleans / numbers
77+
/// raise fastmcpp::ValidationError instead of silently passing through.
78+
Json build_typed_params(const std::unordered_map<std::string, std::string>& raw) const;
5979
};
6080

6181
/// Extract path parameters from URI template: {var}, {var*}
@@ -73,4 +93,10 @@ std::string url_decode(const std::string& encoded);
7393
/// URL-encode a string
7494
std::string url_encode(const std::string& decoded);
7595

96+
/// Coerce a string query-/path-param value into a typed JSON value according to kind.
97+
/// Throws fastmcpp::ValidationError when the value does not match the declared kind
98+
/// (e.g., kind == Boolean but the string is "banana").
99+
/// String kind is a pass-through (returns Json(value)).
100+
Json coerce_param_value(const std::string& value, ParamKind kind, const std::string& param_name);
101+
76102
} // namespace fastmcpp::resources

include/fastmcpp/server/http_server.hpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@
22
#include "fastmcpp/server/server.hpp"
33

44
#include <atomic>
5+
#include <functional>
56
#include <memory>
67
#include <string>
78
#include <thread>
89
#include <unordered_map>
10+
#include <vector>
911

1012
namespace httplib
1113
{
1214
class Server;
1315
class Response;
1416
}
1517

18+
namespace fastmcpp
19+
{
20+
struct CustomRoute;
21+
}
22+
1623
namespace fastmcpp::server
1724
{
1825

@@ -36,6 +43,12 @@ class HttpServerWrapper
3643
std::unordered_map<std::string, std::string> response_headers = {});
3744
~HttpServerWrapper();
3845

46+
/// Register a custom HTTP route (e.g. `/health`) handled before the
47+
/// catch-all JSON-RPC POST. Must be called before start(); routes
48+
/// registered after start() take effect on the next start() call. Parity
49+
/// hook for Python `FastMCP.custom_route()` aggregation (commit 68e76fea).
50+
void set_custom_routes(std::vector<fastmcpp::CustomRoute> routes);
51+
3952
bool start();
4053
void stop();
4154
bool running() const
@@ -69,6 +82,7 @@ class HttpServerWrapper
6982
std::atomic<int> bound_port_ = 0;
7083
std::string auth_token_; // Optional Bearer token for authentication
7184
std::unordered_map<std::string, std::string> response_headers_;
85+
std::vector<fastmcpp::CustomRoute> custom_routes_;
7286
std::unique_ptr<httplib::Server> svr_;
7387
std::thread thread_;
7488
std::atomic<bool> running_{false};

include/fastmcpp/tools/tool.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,20 @@ class Tool
199199
return *this;
200200
}
201201

202+
/// Free-form metadata attached to this tool — surfaces in MCP `_meta`
203+
/// when a caller chooses to serialize it. Used by CatalogTransform to
204+
/// publish `meta.fastmcp.versions` under the dedup contract (Python
205+
/// fastmcp commit 03673d9f).
206+
const std::optional<fastmcpp::Json>& meta() const
207+
{
208+
return meta_;
209+
}
210+
Tool& set_meta(fastmcpp::Json meta)
211+
{
212+
meta_ = std::move(meta);
213+
return *this;
214+
}
215+
202216
private:
203217
static std::string format_timeout_seconds(std::chrono::milliseconds timeout)
204218
{
@@ -262,6 +276,7 @@ class Tool
262276
std::optional<fastmcpp::Json> annotations_;
263277
std::optional<fastmcpp::AppConfig> app_;
264278
std::optional<std::string> version_;
279+
std::optional<fastmcpp::Json> meta_;
265280
};
266281

267282
} // namespace fastmcpp::tools

include/fastmcpp/tools/tool_transform.hpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/// - TransformedTool: Creates a new Tool by transforming another
88
/// - Schema transformation utilities
99

10+
#include "fastmcpp/exceptions.hpp"
1011
#include "fastmcpp/tools/tool.hpp"
1112
#include "fastmcpp/types.hpp"
1213

@@ -64,6 +65,11 @@ struct TransformResult
6465
};
6566

6667
/// Build a transformed schema from parent schema and transforms
68+
///
69+
/// Throws fastmcpp::ValidationError if the requested transforms would map two
70+
/// distinct parent arguments to the same effective name (rename collides with
71+
/// either another rename or an untouched passthrough param). Parity with
72+
/// Python fastmcp commit d316f193.
6773
inline TransformResult
6874
build_transformed_schema(const Json& parent_schema,
6975
const std::unordered_map<std::string, ArgTransform>& transform_args)
@@ -82,6 +88,33 @@ build_transformed_schema(const Json& parent_schema,
8288
required_set.insert(r.get<std::string>());
8389
}
8490

91+
// Pre-flight: detect effective-name collisions across the FULL parent
92+
// param set (renames + passthroughs). Walk parent params in the same order
93+
// so the error message names the first colliding pair deterministically.
94+
{
95+
std::unordered_map<std::string, std::string> seen_owner; // effective_name -> parent_name
96+
for (auto& [old_name, _prop] : properties.items())
97+
{
98+
auto it = transform_args.find(old_name);
99+
if (it != transform_args.end() && it->second.hide)
100+
continue; // hidden args do not occupy an effective slot
101+
102+
std::string effective =
103+
(it != transform_args.end() && it->second.name.has_value())
104+
? *it->second.name
105+
: old_name;
106+
107+
auto inserted = seen_owner.emplace(effective, old_name);
108+
if (!inserted.second)
109+
{
110+
throw fastmcpp::ValidationError(
111+
"Multiple arguments would be mapped to the same name: '" + effective +
112+
"' (from parent params '" + inserted.first->second + "' and '" + old_name +
113+
"')");
114+
}
115+
}
116+
}
117+
85118
// Process transforms
86119
Json new_properties = Json::object();
87120
std::unordered_set<std::string> new_required;

0 commit comments

Comments
 (0)