From 857c00d7674757598f43e70e6ca66fd0cda4a441 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Tue, 12 May 2026 23:37:57 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(parity):=20F1=20+=20F2=20+=20F11=20+?= =?UTF-8?q?=20F12=20=E2=80=94=20initial=20Phase=203a=20catch-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles four small parity items from the v3.3.0b2 catch-up cycle: F1 (T10 baseline regression — tool.version) - ToolInfo::from_json: also surface version from _meta.fastmcp.version (Python encodes version there; we still emit "version" at top level on serialize). Fixes T10 "version_field_present" interop test. - ProxyApp tools/list handler: emit tool_info.version when present (parity with FastMCP-side tools/list builders). F2 (Python commit 970b92bb): allow hyphens in resource template params. - ResourceTemplate::match: keys returned to providers normalized (`-` → `_`) so `{user-id}` exposes "user_id". - Mirrors Python re-named-group convention (Python identifiers cannot contain hyphens). F11 (Python commit 73b7f2e4 #4036): add log_level to FastMCP errors. - exceptions.hpp: add `log_level` int field (Python `logging` constants Debug=10..Critical=50) on Error base class with default Error and accessors. Reused as `int` (not enum) to avoid colliding with the existing `fastmcpp::server::LogLevel` symbol. F12 (Python commit a010927e #4042): experimental_capabilities kwarg. - FastMCP: add experimental_capabilities() accessor + setter. - handler.cpp: new advertise_experimental() helper called at all three FastMCP `initialize` builders (1873/2579/3048-equivalent). ProxyApp `initialize` intentionally skipped — ProxyApp does not own that field. Verified: - cmake --build (Debug): green. - ctest -E fastmcpp_stdio_timeout (Debug): 102/102. - tests/fastmcpp/tests/run_interop_tests.py --test T10 --category version (Release): 3/3 tests pass (was 2/3 with version_field_present failing on the v3.3.0b2 baseline before this commit). Reference: fastmcp commits 970b92bb, 73b7f2e4, a010927e (and the post-v3.3.0b2 _meta.fastmcp.version exposure that surfaced T10). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- include/fastmcpp/app.hpp | 14 +++++++++++++ include/fastmcpp/client/types.hpp | 21 ++++++++++++++++++- include/fastmcpp/exceptions.hpp | 35 ++++++++++++++++++++++++++++++- src/mcp/handler.cpp | 16 ++++++++++++++ src/resources/template.cpp | 17 +++++++++++++-- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/include/fastmcpp/app.hpp b/include/fastmcpp/app.hpp index fcfb63b..46c4399 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -197,6 +197,19 @@ class FastMCP return dereference_schemas_; } + /// Experimental capabilities advertised on `initialize`. Mirrors Python + /// `FastMCP(experimental_capabilities=...)` (#4042 / commit `a010927e`): + /// the dict is propagated verbatim under `capabilities.experimental` so + /// servers can announce protocol extensions without a structural change. + const std::optional& experimental_capabilities() const + { + return experimental_capabilities_; + } + void set_experimental_capabilities(std::optional caps) + { + experimental_capabilities_ = std::move(caps); + } + // Manager accessors tools::ToolManager& tools() { @@ -384,6 +397,7 @@ class FastMCP mutable std::vector provider_prompts_cache_; int list_page_size_{0}; bool dereference_schemas_{true}; + std::optional experimental_capabilities_; // Prefix utilities static std::string add_prefix(const std::string& name, const std::string& prefix); diff --git a/include/fastmcpp/client/types.hpp b/include/fastmcpp/client/types.hpp index fb5720b..62f23a5 100644 --- a/include/fastmcpp/client/types.hpp +++ b/include/fastmcpp/client/types.hpp @@ -375,7 +375,13 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t) { t.name = j.at("name").get(); if (j.contains("version")) - t.version = j["version"].get(); + { + // Python fastmcp encodes Tool.version as a string, but historic clients may send an int. + if (j["version"].is_string()) + t.version = j["version"].get(); + else if (j["version"].is_number_integer()) + t.version = std::to_string(j["version"].get()); + } if (j.contains("title")) t.title = j["title"].get(); if (j.contains("description")) @@ -392,6 +398,19 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t) t._meta = j["_meta"]; if (j["_meta"].is_object() && j["_meta"].contains("ui") && j["_meta"]["ui"].is_object()) t.app = j["_meta"]["ui"].get(); + // Python fastmcp >= 2.x exposes per-tool version via _meta.fastmcp.version (see + // fastmcp_slim/fastmcp/utilities/components.py:get_meta). Surface it as ToolInfo.version + // if no top-level "version" was provided so the proxy passthrough preserves the field. + if (!t.version && j["_meta"].is_object() && j["_meta"].contains("fastmcp") + && j["_meta"]["fastmcp"].is_object() + && j["_meta"]["fastmcp"].contains("version")) + { + const auto& v = j["_meta"]["fastmcp"]["version"]; + if (v.is_string()) + t.version = v.get(); + else if (v.is_number_integer()) + t.version = std::to_string(v.get()); + } } } diff --git a/include/fastmcpp/exceptions.hpp b/include/fastmcpp/exceptions.hpp index de5918a..c92755a 100644 --- a/include/fastmcpp/exceptions.hpp +++ b/include/fastmcpp/exceptions.hpp @@ -5,9 +5,42 @@ namespace fastmcpp { +/// Python `logging` module integer level constants. Mirrors Python fastmcp +/// commit 73b7f2e4 (#4036) which added `FastMCPError.log_level` so +/// downstream logging adapters can dispatch per-error severity. Values match +/// Python `logging.{DEBUG,INFO,WARNING,ERROR,CRITICAL}`. +namespace log_level +{ +constexpr int Debug = 10; +constexpr int Info = 20; +constexpr int Warning = 30; +constexpr int Error = 40; +constexpr int Critical = 50; +} // namespace log_level + struct Error : public std::runtime_error { - using std::runtime_error::runtime_error; + Error(const std::string& msg, int level = log_level::Error) + : std::runtime_error(msg), log_level_(level) + { + } + Error(const char* msg, int level = log_level::Error) + : std::runtime_error(msg), log_level_(level) + { + } + + /// Python `logging` integer level (10 Debug … 50 Critical). See `log_level::*` constants. + int log_level() const noexcept + { + return log_level_; + } + void set_log_level(int level) noexcept + { + log_level_ = level; + } + + private: + int log_level_{log_level::Error}; }; struct NotFoundError : public Error diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index e70c774..71f72e4 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -62,6 +62,16 @@ static void attach_meta_ui(fastmcpp::Json& entry, const std::optionalis_object() && !exp->empty()) + capabilities["experimental"] = *exp; +} + static std::string normalize_resource_uri(std::string uri) { while (uri.size() > 1 && !uri.empty() && uri.back() == '/') @@ -1871,6 +1881,7 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) if (!app.list_all_prompts().empty()) capabilities["prompts"] = fastmcpp::Json::object(); advertise_ui_extension(capabilities); + advertise_experimental(capabilities, app); fastmcpp::Json result_obj = {{"protocolVersion", "2024-11-05"}, {"capabilities", capabilities}, @@ -2577,6 +2588,8 @@ std::function make_mcp_handler(const Prox if (!app.list_all_prompts().empty()) capabilities["prompts"] = fastmcpp::Json::object(); advertise_ui_extension(capabilities); + // Note: ProxyApp does not propagate experimental_capabilities (that field is + // FastMCP-only); skip advertise_experimental here intentionally. fastmcpp::Json result_obj = {{"protocolVersion", "2024-11-05"}, {"capabilities", capabilities}, @@ -2600,6 +2613,8 @@ std::function make_mcp_handler(const Prox { fastmcpp::Json tool_json = {{"name", tool.name}, {"inputSchema", tool.inputSchema}}; + if (tool.version) + tool_json["version"] = *tool.version; if (tool.description) tool_json["description"] = *tool.description; if (tool.title) @@ -3046,6 +3061,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces if (!app.list_all_prompts().empty()) capabilities["prompts"] = fastmcpp::Json::object(); advertise_ui_extension(capabilities); + advertise_experimental(capabilities, app); fastmcpp::Json result_obj = {{"protocolVersion", "2024-11-05"}, {"capabilities", capabilities}, diff --git a/src/resources/template.cpp b/src/resources/template.cpp index b07bada..4cc1c58 100644 --- a/src/resources/template.cpp +++ b/src/resources/template.cpp @@ -20,6 +20,19 @@ std::string to_lower(std::string s) return s; } +// Python fastmcp commit 970b92bb: parameter names with hyphens are exposed to providers as +// underscore-keyed entries (Python identifiers cannot contain hyphens, and `re` named groups +// follow the same rule). We mirror this for cross-implementation parity so a template such as +// `data://{user-id}/{first-name}` produces params {"user_id": ..., "first_name": ...}. +std::string normalize_param_key(const std::string& name) +{ + std::string result; + result.reserve(name.size()); + for (char c : name) + result.push_back(c == '-' ? '_' : c); + return result; +} + ParamKind kind_from_schema_type(const std::string& schema_type) { if (schema_type == "boolean") @@ -405,7 +418,7 @@ ResourceTemplate::match(const std::string& uri) const std::string value = pair.substr(eq_pos + 1); if (key == param.name) - params[param.name] = url_decode(value); + params[normalize_param_key(param.name)] = url_decode(value); } } } @@ -438,7 +451,7 @@ ResourceTemplate::match(const std::string& uri) const } if (group_index < static_cast(match.size())) - params[param.name] = url_decode(match[group_index].str()); + params[normalize_param_key(param.name)] = url_decode(match[group_index].str()); } } From 43ce8ce208921a3503e1e07ee2b6f0530a7ede2e Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Tue, 12 May 2026 23:45:33 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(parity):=20F3=20+=20F4=20+=20F5=20+=20?= =?UTF-8?q?F6=20+=20F7=20=E2=80=94=20Phase=203a=20schema/transform=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3 (Python commit b8597f94 #4101): hoist $defs to schema root in tool_transform. - tool_transform.hpp build_transformed_schema: when ArgTransform.type_schema contains $defs, hoist them into result.schema["$defs"] so MCP clients can resolve $ref entries from the transformed property. - Preserves any hoisted $defs across the result.schema = parent_schema reset. F4 + F5 (Python commits 4b59e0d9 #3818 + e5b96343 #3785): boolean schemas + empty enums. - json_schema_type.cpp convert(): JSON Schema permits boolean schemas at any level. `true` accepts any value (pass through); `false` rejects all (ValidationError). Previously crashed with json::type_error on schema["type"].get(). - enforce_enum_const: explicitly reject non-array enum and treat empty enum as no-match (previous code would simply not match any value, but on a non-array enum would crash). F6 (Python commit 789a2986 #3959): graceful regex_error fallback in json_schema_to_type. - cached_regex(): try/catch std::regex_error. On failure, return nullptr so caller can drop the constraint. Mirrors Python behavior of silently dropping unsupported regex patterns (lookahead, certain Unicode escapes) so real-world OpenAPI specs (AWS, Azure) do not crash. - cached_regex_required(): retained reference-returning overload for our own built-in patterns (email/uri/date-time) which are guaranteed valid. F7 (Python commit 923695bd #3682): strip discriminator after dereferencing. - json_schema.cpp: new strip_discriminator() recursively removes `discriminator` keys at union (anyOf/oneOf) boundaries after $defs inlining. Properties literally named "discriminator" inside `properties:` are preserved. - Called from dereference_refs() right before returning. Verified: - cmake --build (Debug): green. - ctest -E fastmcpp_stdio_timeout (Debug): 102/102 passing. - tests/fastmcpp/tests/run_interop_tests.py --test all (Release): 241/241 tests across 9 scenarios (was 239/240 with T10 version_field failing on the v3.3.0b2 baseline before F1). Reference: fastmcp commits b8597f94, 4b59e0d9, e5b96343, 789a2986, 923695bd. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- include/fastmcpp/tools/tool_transform.hpp | 37 +++++++++++++++- src/util/json_schema.cpp | 35 +++++++++++++++ src/util/json_schema_type.cpp | 53 +++++++++++++++++++---- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/include/fastmcpp/tools/tool_transform.hpp b/include/fastmcpp/tools/tool_transform.hpp index 99b21d8..8949964 100644 --- a/include/fastmcpp/tools/tool_transform.hpp +++ b/include/fastmcpp/tools/tool_transform.hpp @@ -145,8 +145,30 @@ build_transformed_schema(const Json& parent_schema, new_prop["description"] = *transform.description; if (transform.type_schema.has_value()) + { + // Python fastmcp commit b8597f94 (#4101): hoist `$defs` introduced by an + // ArgTransform.type_schema to the schema root so MCP clients can resolve any + // $ref the new property type references. Without this, properties using complex + // annotated types reference `$defs/X` that does not exist anywhere in the + // emitted schema. + Json hoisted_defs = Json::object(); for (auto& [k, v] : transform.type_schema->items()) + { + if (k == "$defs" && v.is_object()) + { + hoisted_defs = v; + continue; // do not also write it under the property + } new_prop[k] = v; + } + if (!hoisted_defs.empty()) + { + if (!result.schema.contains("$defs") || !result.schema["$defs"].is_object()) + result.schema["$defs"] = Json::object(); + for (auto& [dk, dv] : hoisted_defs.items()) + result.schema["$defs"][dk] = dv; + } + } if (transform.default_value.has_value()) new_prop["default"] = *transform.default_value; @@ -179,7 +201,20 @@ build_transformed_schema(const Json& parent_schema, } // Build result schema - result.schema = parent_schema; + { + // Preserve any $defs we hoisted above before overwriting properties/required. + Json hoisted = Json::object(); + if (result.schema.contains("$defs") && result.schema["$defs"].is_object()) + hoisted = result.schema["$defs"]; + result.schema = parent_schema; + if (!hoisted.empty()) + { + if (!result.schema.contains("$defs") || !result.schema["$defs"].is_object()) + result.schema["$defs"] = Json::object(); + for (auto& [dk, dv] : hoisted.items()) + result.schema["$defs"][dk] = dv; + } + } result.schema["properties"] = new_properties; result.schema["required"] = Json::array(); for (const auto& r : new_required) diff --git a/src/util/json_schema.cpp b/src/util/json_schema.cpp index 8ad5d7e..0f10949 100644 --- a/src/util/json_schema.cpp +++ b/src/util/json_schema.cpp @@ -204,6 +204,39 @@ bool contains_ref(const Json& schema) return contains_ref_impl(schema); } +// Recursively remove `discriminator` keys at union boundaries (anyOf/oneOf) after refs were +// inlined. Mirrors Python `_strip_discriminator()` (commit 923695bd / #3682): once $defs are +// dereferenced, OpenAPI discriminator hints reference now-removed paths and confuse strict +// JSON Schema validators. Properties literally named "discriminator" inside `properties:` are +// preserved (only the schema-level keyword is stripped). +static void strip_discriminator(Json& node) +{ + if (node.is_array()) + { + for (auto& item : node) + strip_discriminator(item); + return; + } + if (!node.is_object()) + return; + bool has_union = node.contains("anyOf") || node.contains("oneOf"); + if (has_union && node.contains("discriminator")) + node.erase("discriminator"); + for (auto& [key, value] : node.items()) + { + if (key == "properties" && value.is_object()) + { + // Recurse into property schemas, but do not touch a literal "discriminator" property. + for (auto& [pname, psub] : value.items()) + strip_discriminator(psub); + } + else + { + strip_discriminator(value); + } + } +} + Json dereference_refs(const Json& schema) { if (!schema.is_object() && !schema.is_array()) @@ -219,6 +252,8 @@ Json dereference_refs(const Json& schema) !contains_ref_impl(dereferenced)) dereferenced.erase("$defs"); + strip_discriminator(dereferenced); + return dereferenced; } diff --git a/src/util/json_schema_type.cpp b/src/util/json_schema_type.cpp index 8aa1f47..965e140 100644 --- a/src/util/json_schema_type.cpp +++ b/src/util/json_schema_type.cpp @@ -32,14 +32,34 @@ std::unordered_map& regex_cache() return cache; } -const std::regex& cached_regex(const std::string& key, const std::string& pattern) +const std::regex* cached_regex(const std::string& key, const std::string& pattern) noexcept { auto& cache = regex_cache(); auto it = cache.find(key); if (it != cache.end()) - return it->second; - auto [ins_it, _] = cache.emplace(key, std::regex(pattern)); - return ins_it->second; + return &it->second; + // Python fastmcp commit 789a2986 (#3959): silently drop unsupported regex patterns + // (lookahead, certain Unicode escapes) so real-world OpenAPI specs (AWS, Azure) do + // not crash json_schema_to_value. Return nullptr — callers must treat as "no + // pattern constraint". + try + { + auto [ins_it, _] = cache.emplace(key, std::regex(pattern)); + return &ins_it->second; + } + catch (const std::regex_error&) + { + return nullptr; + } +} + +// Backward-compatible reference overload for built-in patterns guaranteed to compile. +const std::regex& cached_regex_required(const std::string& key, const std::string& pattern) +{ + auto* p = cached_regex(key, pattern); + if (!p) + throw fastmcpp::ValidationError("internal regex compile failure for built-in pattern: " + key); + return *p; } SchemaValue convert(const fastmcpp::Json& schema, const fastmcpp::Json& instance, @@ -74,6 +94,9 @@ void enforce_enum_const(const fastmcpp::Json& schema, const fastmcpp::Json& inst } if (schema.contains("enum")) { + // Python fastmcp commit 4b59e0d9 (#3818): empty enum still rejects gracefully. + if (!schema["enum"].is_array()) + throw fastmcpp::ValidationError("Enum schema must be an array at " + path); bool ok = false; for (const auto& v : schema["enum"]) { @@ -116,13 +139,13 @@ SchemaValue handle_string(const fastmcpp::Json& schema, const fastmcpp::Json& in } else if (fmt == "email") { - const auto& email_re = cached_regex("email", R"(^[^@\s]+@[^@\s]+\.[^@\s]+$)"); + const auto& email_re = cached_regex_required("email", R"(^[^@\s]+@[^@\s]+\.[^@\s]+$)"); if (!std::regex_match(value, email_re)) throw fastmcpp::ValidationError("Invalid email format at " + path); } else if (fmt == "uri" || fmt == "uri-reference") { - const auto& uri_re = cached_regex("uri", R"(^[a-zA-Z][a-zA-Z0-9+.-]*://.+)"); + const auto& uri_re = cached_regex_required("uri", R"(^[a-zA-Z][a-zA-Z0-9+.-]*://.+)"); if (fmt == "uri" && !std::regex_match(value, uri_re)) throw fastmcpp::ValidationError("Invalid uri format at " + path); // uri-reference may be relative; allow any non-empty string @@ -130,7 +153,7 @@ SchemaValue handle_string(const fastmcpp::Json& schema, const fastmcpp::Json& in else if (fmt == "date-time") { const auto& dt_re = - cached_regex("date-time", R"(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$)"); + cached_regex_required("date-time", R"(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$)"); if (!std::regex_match(value, dt_re)) throw fastmcpp::ValidationError("Invalid date-time format at " + path); } @@ -141,9 +164,11 @@ SchemaValue handle_string(const fastmcpp::Json& schema, const fastmcpp::Json& in throw fastmcpp::ValidationError("maxLength violation at " + path); if (schema.contains("pattern") && schema["pattern"].is_string()) { - const auto& pat = cached_regex(schema["pattern"].get(), + // Python fastmcp commit 789a2986 (#3959): if the pattern fails to compile (lookahead, + // unsupported Unicode escapes, etc.), gracefully drop the constraint instead of crashing. + const auto* pat = cached_regex(schema["pattern"].get(), schema["pattern"].get()); - if (!std::regex_match(value, pat)) + if (pat && !std::regex_match(value, *pat)) throw fastmcpp::ValidationError("pattern violation at " + path); } return value; @@ -332,6 +357,16 @@ SchemaValue handle_object(const fastmcpp::Json& schema, const fastmcpp::Json& in SchemaValue convert(const fastmcpp::Json& schema, const fastmcpp::Json& instance, const std::string& path) { + // JSON Schema permits boolean schemas: `true` accepts any value; `false` rejects all. + // Python fastmcp commits 4b59e0d9 (#3818) and e5b96343 (#3785) ensure these don't crash + // json_schema_to_value when they appear as a property schema or root schema. + if (schema.is_boolean()) + { + if (schema.get()) + return instance; // true: accept-any, pass through + throw fastmcpp::ValidationError("schema=false rejects all values at " + path); + } + // Union type via "type": ["a","b"] if (schema.contains("type") && schema["type"].is_array()) { From 685aabb133137385c0ef22a637966b81fc342153 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Tue, 12 May 2026 23:49:40 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat(parity):=20F8=20+=20F9=20+=20F10=20?= =?UTF-8?q?=E2=80=94=20Phase=203a/3b=20OpenAPI=20nullable=20+=20lifecycle?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F8 (Python commit 042db1d0 #3768): OpenAPI 3.0 `nullable: true` in tool input schemas. - openapi_provider.cpp consume_parameters: convert `{type: string, nullable: true}` to JSON Schema `type: ["string", "null"]` so json_schema_to_value and downstream validators accept null. Also handles the no-`type`-key case (becomes `type: "null"`). `nullable` key stripped. F9 (Python commit 82090938 #4118): drain in-flight Streamable HTTP responses on stop. - StreamableHttpServerWrapper::stop(): reorder so svr_->stop() and thread_.join() complete BEFORE clearing sessions_. This keeps the sessions map populated during shutdown so any handler still flushing a final SSE event can still resolve its session_id, mirroring Python's "terminate active transports before lifespan teardown" intent. F10 (Python commit 4bbc4eec #3756): ResponseLimitingMiddleware outputSchema bypass. - response_limiting_middleware.cpp make_hook(): when truncating a tools/call response, drop `structuredContent` (no longer matches the registered outputSchema) and set `_meta = {}` (non-null) so MCP SDK clients accept the truncated response as a vanilla CallToolResult and bypass outputSchema validation. Applied at both shapes (route payload and JSON-RPC envelope `result`). Verified: - cmake --build (Debug): green. - ctest -E fastmcpp_stdio_timeout (Debug): 102/102 passing. Reference: fastmcp commits 042db1d0, 82090938, 4bbc4eec. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/providers/openapi_provider.cpp | 16 ++++++++++++++++ src/server/response_limiting_middleware.cpp | 16 ++++++++++++++++ src/server/streamable_http_server.cpp | 16 ++++++++++------ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp index 248bc81..3ffac2b 100644 --- a/src/providers/openapi_provider.cpp +++ b/src/providers/openapi_provider.cpp @@ -268,6 +268,22 @@ std::vector OpenAPIProvider::parse_routes() co Json schema = Json{{"type", "string"}}; if (param.contains("schema") && param["schema"].is_object()) schema = param["schema"]; + // Python fastmcp commit 042db1d0 (#3768): convert OpenAPI 3.0 + // `nullable: true` to JSON Schema's `type: ["X", "null"]` form so + // downstream JSON Schema validators (and json_schema_to_value) accept null. + if (schema.is_object() && schema.value("nullable", false)) + { + if (schema.contains("type") && schema["type"].is_string()) + { + std::string t = schema["type"].get(); + schema["type"] = Json::array({t, "null"}); + } + else if (!schema.contains("type")) + { + schema["type"] = "null"; + } + schema.erase("nullable"); + } if (param.contains("description") && param["description"].is_string() && (!schema.contains("description") || !schema["description"].is_string())) schema["description"] = param["description"]; diff --git a/src/server/response_limiting_middleware.cpp b/src/server/response_limiting_middleware.cpp index 9a58ce8..d90c6ae 100644 --- a/src/server/response_limiting_middleware.cpp +++ b/src/server/response_limiting_middleware.cpp @@ -65,6 +65,22 @@ AfterHook ResponseLimitingMiddleware::make_hook() const // Replace content with single truncated text entry *content = fastmcpp::Json::array(); content->push_back(fastmcpp::Json{{"type", "text"}, {"text", truncated}}); + + // Python fastmcp commit 4bbc4eec (#3756): when ResponseLimitingMiddleware truncates + // a tool result, drop `structuredContent` (it no longer matches the registered + // outputSchema after truncation) and signal bypass via `_meta = {}` so MCP SDK + // clients accept the response as a vanilla CallToolResult instead of failing + // outputSchema validation. Apply at both shapes (route payload + JSON-RPC envelope). + auto bypass_output_schema = [](fastmcpp::Json& obj) { + if (obj.contains("structuredContent")) + obj.erase("structuredContent"); + if (!obj.contains("_meta") || !obj["_meta"].is_object()) + obj["_meta"] = fastmcpp::Json::object(); + }; + if (response.contains("content") && response["content"].is_array()) + bypass_output_schema(response); + else if (response.contains("result") && response["result"].is_object()) + bypass_output_schema(response["result"]); }; } diff --git a/src/server/streamable_http_server.cpp b/src/server/streamable_http_server.cpp index 52cd2cd..2f88ebb 100644 --- a/src/server/streamable_http_server.cpp +++ b/src/server/streamable_http_server.cpp @@ -383,17 +383,21 @@ void StreamableHttpServerWrapper::stop() { running_ = false; - // Clear sessions - { - std::lock_guard lock(sessions_mutex_); - sessions_.clear(); - } - + // Python fastmcp commit 82090938 (#4118): drain active streamable-HTTP responses + // before tearing down session state. httplib's svr_->stop() begins quiescing + // in-flight requests; we keep `sessions_` populated so any handler still flushing + // a final SSE event can resolve its session_id, then clear after the listening + // thread joins (i.e. all handlers have returned). if (svr_) svr_->stop(); if (thread_.joinable()) thread_.join(); + { + std::lock_guard lock(sessions_mutex_); + sessions_.clear(); + } + bound_port_.store(0); // Reset the bound port's value. } From 31712ff94da1505731e9c85c1f8acdfff4d81e14 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Wed, 13 May 2026 00:34:44 -0700 Subject: [PATCH 4/7] =?UTF-8?q?feat(parity):=20F14=20+=20F17=20+=20F18=20+?= =?UTF-8?q?=20F19=20+=20F20=20+=20F21=20+=20F22=20=E2=80=94=20close=20out?= =?UTF-8?q?=20catch-up=20cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F14 (Python commit d6b55c0b #3857): raise on unhandled content types in sampling. - sampling_handlers.cpp build_openai_messages: detect image/audio/unknown content types and throw a clear error rather than silently dropping. F16 (image/audio request-side conversion) deferred — explicit error message points at it so callers know. - build_anthropic_messages: same; audio additionally raises with note that Anthropic Messages API does not accept audio. F17 (Python commit 556fd8fa #3778): harden client.call_tool error handling. - parse_call_tool_result: missing/non-array `content` no longer raises ValidationError; returns empty CallToolResult so older servers / partial responses don't crash callers. - Test api_advanced.cpp `test_call_tool_error_and_data` updated to assert the new behavior (intrinsically coupled to source change; permitted in-submodule edit per workflow rules). F18 (Python commit f5804f47 #3630): recover StdioTransport after subprocess exit. - StdioTransport::request: on entry to the keep_alive branch, if state_ is populated and the subprocess has already exited, tear down state_ (join stderr thread) so the next call respawns cleanly. Previously a dead subprocess caused every subsequent request to throw forever. F19 (Python commit 99eaeb8a #3770): substitute server-variable defaults in OpenAPI base URL. - openapi_provider.cpp ctor: when reading servers[0].url, expand `{varName}` placeholders using servers[0].variables..default so specs with `"url": "{protocol}://api.example.com"` produce a usable base URL. Non-string defaults serialized via .dump() as fallback. F20 (Python commits 6f30e89d #3595 + 16eb2ffc #3662): query param style/explode. - openapi_provider.cpp invoke_route: arrays expand to multiple `key=val` pairs (OpenAPI default style=form, explode=true); objects expand to individual `prop=val` pairs (default explode=true drops the outer param name). Per-param style/explode override metadata not yet plumbed through RouteDefinition — partial implementation noted in kb/sync/review_result.md F20. F21 (Python commits 7dd57398 #3932 + ca76b828 #3611): body content-type dispatch. - parse_routes: prefer `application/json` requestBody schema; fall back to `application/x-www-form-urlencoded`, then `multipart/form-data` (capture only — multipart serialization not yet implemented). Stored on RouteDefinition::request_content_type. - invoke_route: when content type is form-urlencoded and body is an object, serialize as `key=val&...`. Otherwise dump JSON. The declared content type is now sent in the HTTP Content-Type header. F22: version bump 3.1.1 → 3.3.0 in CMakeLists.txt (matches upstream fastmcp v3.3.0b2 reference SHA ee48a0fd). Verified: - cmake --build (Debug): green. - ctest -E fastmcpp_stdio_timeout (Debug): 102/102 passing. - tests/fastmcpp/tests/run_interop_tests.py --test all (Release): 241/241 tests across 9 scenarios. Reference: fastmcp commits d6b55c0b, 556fd8fa, f5804f47, 99eaeb8a, 6f30e89d, 16eb2ffc, 7dd57398, ca76b828. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 2 +- include/fastmcpp/client/client.hpp | 15 ++- .../fastmcpp/providers/openapi_provider.hpp | 1 + src/client/sampling_handlers.cpp | 34 +++++ src/client/transports.cpp | 15 +++ src/providers/openapi_provider.cpp | 121 +++++++++++++++--- tests/client/api_advanced.cpp | 13 +- 7 files changed, 176 insertions(+), 25 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e0e6878..c74687e 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.1 LANGUAGES CXX) +project(fastmcpp VERSION 3.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/include/fastmcpp/client/client.hpp b/include/fastmcpp/client/client.hpp index 4db7301..530913c 100644 --- a/include/fastmcpp/client/client.hpp +++ b/include/fastmcpp/client/client.hpp @@ -1042,12 +1042,19 @@ class Client CallToolResult result; result.isError = body.value("isError", false); - if (!body.contains("content")) - throw fastmcpp::ValidationError("tools/call response missing content"); - - if (body.contains("content")) + // Python fastmcp commit 556fd8fa (#3778): harden client-side parsing of malformed + // tools/call results. + // - Missing "content" with isError=true is permitted (server may have raised before + // producing content); raise_on_error is handled at a higher layer using result.text(). + // - Missing "content" with isError=false is treated as an empty result rather than a + // ValidationError so older servers / partial responses do not crash the client. + // - "content" present but not an array is treated as empty (do not crash). + if (body.contains("content") && body["content"].is_array()) + { for (const auto& c : body["content"]) result.content.push_back(parse_content_block(c)); + } + // else: leave result.content empty if (body.contains("structuredContent")) { diff --git a/include/fastmcpp/providers/openapi_provider.hpp b/include/fastmcpp/providers/openapi_provider.hpp index 44c4ab1..d1bf659 100644 --- a/include/fastmcpp/providers/openapi_provider.hpp +++ b/include/fastmcpp/providers/openapi_provider.hpp @@ -41,6 +41,7 @@ class OpenAPIProvider : public Provider std::vector path_params; std::vector query_params; bool has_json_body{false}; + std::string request_content_type{"application/json"}; std::optional description; }; diff --git a/src/client/sampling_handlers.cpp b/src/client/sampling_handlers.cpp index 08a4f5b..f20ba04 100644 --- a/src/client/sampling_handlers.cpp +++ b/src/client/sampling_handlers.cpp @@ -243,6 +243,28 @@ static fastmcpp::Json build_openai_messages(const fastmcpp::Json& params) fastmcpp::Json{{"role", "tool"}, {"tool_call_id", tool_use_id}, {"content", text}}); } + // Python fastmcp commit d6b55c0b (#3857): raise on unhandled content types instead of + // silently dropping. The OpenAI compatible handler currently supports text + tool_use + + // tool_result; image/audio request-body conversion (F16 / commit 734b93b9) is not yet + // implemented in C++, so raise a clear error rather than producing an empty assistant + // message that misleads upstream code. + for (const auto& block : normalize_content_to_array(content)) + { + if (!block.is_object()) + continue; + const std::string t = block.value("type", ""); + if (t == "text" || t == "tool_use" || t == "tool_result" || t.empty()) + continue; + if (t == "image" || t == "audio") + throw std::runtime_error( + "OpenAI sampling handler: '" + t + + "' content not yet supported (F16 / fastmcp #3550); cannot dispatch sampling " + "request"); + // Unknown type — surface clearly so callers don't get silent data loss. + throw std::runtime_error( + "OpenAI sampling handler: unhandled content type '" + t + "'"); + } + std::string text = join_text_blocks(content); auto tool_uses = extract_blocks_by_type(content, "tool_use"); @@ -388,6 +410,18 @@ static fastmcpp::Json build_anthropic_messages(const fastmcpp::Json& params) blocks.push_back(std::move(out)); continue; } + // Python fastmcp commit d6b55c0b (#3857): raise on unhandled content types. + // Anthropic handler does not support audio inputs at all (Anthropic API doesn't + // accept audio); image is acknowledged but request-body conversion is part of F16 + // (commit 734b93b9) and not yet implemented in C++. + if (type == "image") + throw std::runtime_error( + "Anthropic sampling handler: 'image' content not yet supported " + "(F16 / fastmcp #3550); cannot dispatch sampling request"); + if (type == "audio") + throw std::runtime_error( + "Anthropic sampling handler: 'audio' content is not supported by the " + "Anthropic Messages API"); if (type == "tool_result") { // Anthropic expects tool_use_id and string content. diff --git a/src/client/transports.cpp b/src/client/transports.cpp index e26648a..c97b238 100644 --- a/src/client/transports.cpp +++ b/src/client/transports.cpp @@ -471,6 +471,21 @@ fastmcpp::Json StdioTransport::request(const std::string& route, const fastmcpp: if (keep_alive_) { + // Python fastmcp commit f5804f47 (#3630): if the subprocess died between calls, + // reset state and respawn on the next request rather than failing forever. + if (state_) + { + auto exit_code = state_->process.try_wait(); + if (exit_code.has_value()) + { + // Process already exited — tear down so we respawn cleanly below. + state_->stderr_running.store(false, std::memory_order_release); + if (state_->stderr_thread.joinable()) + state_->stderr_thread.join(); + state_.reset(); + } + } + // --- Keep-alive mode: spawn once, reuse across calls --- if (!state_) { diff --git a/src/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp index 3ffac2b..94593ae 100644 --- a/src/providers/openapi_provider.cpp +++ b/src/providers/openapi_provider.cpp @@ -103,7 +103,37 @@ OpenAPIProvider::OpenAPIProvider(Json openapi_spec, std::optional b !openapi_spec_["servers"].empty() && openapi_spec_["servers"][0].is_object() && openapi_spec_["servers"][0].contains("url") && openapi_spec_["servers"][0]["url"].is_string()) - base_url = openapi_spec_["servers"][0]["url"].get(); + { + std::string url = openapi_spec_["servers"][0]["url"].get(); + // Python fastmcp commit 99eaeb8a (#3770): expand `{varName}` placeholders using the + // declared server variables' defaults so specs like + // servers: [{url: "{protocol}://api.example.com", + // variables: {protocol: {default: "https"}}}] + // are rendered to a valid base URL instead of leaving curly-brace placeholders. + const auto& srv = openapi_spec_["servers"][0]; + if (srv.contains("variables") && srv["variables"].is_object()) + { + for (auto it = srv["variables"].cbegin(); it != srv["variables"].cend(); ++it) + { + if (!it.value().is_object() || !it.value().contains("default")) + continue; + const auto& dv = it.value()["default"]; + std::string repl; + if (dv.is_string()) + repl = dv.get(); + else + repl = dv.dump(); + const std::string placeholder = "{" + it.key() + "}"; + size_t pos = 0; + while ((pos = url.find(placeholder, pos)) != std::string::npos) + { + url.replace(pos, placeholder.size(), repl); + pos += repl.size(); + } + } + } + base_url = std::move(url); + } } if (!base_url || base_url->empty()) throw ValidationError("OpenAPIProvider requires base_url or servers[0].url in spec"); @@ -335,16 +365,26 @@ std::vector OpenAPIProvider::parse_routes() co if (request_body.contains("content") && request_body["content"].is_object()) { const auto& content = request_body["content"]; - if (content.contains("application/json") && - content["application/json"].is_object() && - content["application/json"].contains("schema") && - content["application/json"]["schema"].is_object()) + // Python fastmcp commits 7dd57398 (#3932) + ca76b828 (#3611): respect the + // declared request-body content type. Prefer JSON; fall back to + // application/x-www-form-urlencoded; mark multipart for clarity but + // keep JSON-style serialization for now (multipart not yet implemented). + auto take_schema = [&](const std::string& ct) -> bool { - properties["body"] = content["application/json"]["schema"]; + if (!content.contains(ct) || !content[ct].is_object()) + return false; + if (!content[ct].contains("schema") || !content[ct]["schema"].is_object()) + return false; + properties["body"] = content[ct]["schema"]; + route.request_content_type = ct; route.has_json_body = true; if (request_body.value("required", false)) required.push_back("body"); - } + return true; + }; + if (!take_schema("application/json")) + if (!take_schema("application/x-www-form-urlencoded")) + take_schema("multipart/form-data"); } } @@ -404,14 +444,38 @@ Json OpenAPIProvider::invoke_route(const RouteDefinition& route, const Json& arg std::ostringstream query; bool first = true; + auto append_pair = [&](const std::string& key, const std::string& val) { + query << (first ? "?" : "&"); + first = false; + query << url_encode_component(key) << "=" << url_encode_component(val); + }; for (const auto& param : route.query_params) { if (!arguments.contains(param)) continue; - query << (first ? "?" : "&"); - first = false; - query << url_encode_component(param) << "=" - << url_encode_component(to_string_value(arguments.at(param))); + const auto& val = arguments.at(param); + // Python fastmcp commits 6f30e89d (#3595) + 16eb2ffc (#3662): honor OpenAPI default + // `style=form, explode=true` for arrays and objects. RouteDefinition does not yet + // capture per-param style/explode metadata, so this implements the spec defaults + // (which match upstream's pre-customization behavior). Per-param overrides remain a + // follow-up (see kb/sync/review_result.md F20 partial-implementation note). + if (val.is_array()) + { + // explode=true (default form): emit each element as a separate key=value pair. + for (const auto& el : val) + append_pair(param, to_string_value(el)); + } + else if (val.is_object()) + { + // explode=true (default form): emit each property as a separate prop=value pair + // (the param name is dropped). + for (auto it = val.cbegin(); it != val.cend(); ++it) + append_pair(it.key(), to_string_value(it.value())); + } + else + { + append_pair(param, to_string_value(val)); + } } std::string target = parsed.base_path + resolved_path + query.str(); @@ -419,8 +483,33 @@ Json OpenAPIProvider::invoke_route(const RouteDefinition& route, const Json& arg target = "/" + target; std::string body; + // Python fastmcp commits 7dd57398 (#3932) + ca76b828 (#3611): dispatch on declared + // request-body content type. application/x-www-form-urlencoded → form-encode body + // arguments (object only); multipart/form-data not yet implemented (defaults to JSON + // dump with the multipart content-type header so callers see a clear server-side + // error rather than silent garbage). JSON path unchanged. + const std::string& ct = route.request_content_type; if (route.has_json_body && arguments.contains("body")) - body = arguments["body"].dump(); + { + if (ct == "application/x-www-form-urlencoded" && arguments["body"].is_object()) + { + std::ostringstream form; + bool form_first = true; + for (auto it = arguments["body"].cbegin(); it != arguments["body"].cend(); ++it) + { + if (!form_first) + form << "&"; + form_first = false; + form << url_encode_component(it.key()) << "=" + << url_encode_component(to_string_value(it.value())); + } + body = form.str(); + } + else + { + body = arguments["body"].dump(); + } + } std::unique_ptr client; if (parsed.scheme == "http") @@ -445,13 +534,13 @@ Json OpenAPIProvider::invoke_route(const RouteDefinition& route, const Json& arg if (m == "GET") response = client->Get(target.c_str()); else if (m == "POST") - response = client->Post(target.c_str(), body, "application/json"); + response = client->Post(target.c_str(), body, ct.c_str()); else if (m == "PUT") - response = client->Put(target.c_str(), body, "application/json"); + response = client->Put(target.c_str(), body, ct.c_str()); else if (m == "PATCH") - response = client->Patch(target.c_str(), body, "application/json"); + response = client->Patch(target.c_str(), body, ct.c_str()); else if (m == "DELETE") - response = client->Delete(target.c_str(), body, "application/json"); + response = client->Delete(target.c_str(), body, ct.c_str()); else throw ValidationError("Unsupported OpenAPI HTTP method: " + route.method); diff --git a/tests/client/api_advanced.cpp b/tests/client/api_advanced.cpp index 7950c37..6e255e8 100644 --- a/tests/client/api_advanced.cpp +++ b/tests/client/api_advanced.cpp @@ -90,16 +90,21 @@ void test_call_tool_error_and_data() int val = client::get_data_as(structured); assert(val == 42); - bool missing_content = false; + // Python fastmcp commit 556fd8fa (#3778): missing/malformed `content` no longer raises; + // instead the client returns an empty CallToolResult so older servers / partial responses + // don't crash the call site. Verify the new behavior. + bool missing_content_threw = false; try { - c.call_tool_mcp("bad_response", Json::object()); + auto bad = c.call_tool_mcp("bad_response", Json::object()); + assert(bad.content.empty()); + assert(!bad.isError); } catch (const fastmcpp::ValidationError&) { - missing_content = true; + missing_content_threw = true; } - assert(missing_content); + assert(!missing_content_threw); std::cout << " [PASS] errors throw and structuredContent populates data\n"; } From 73050ffb002666370ebe567d3697f3c428e63189 Mon Sep 17 00:00:00 2001 From: 0xeb <0xeb@users.noreply.github.com> Date: Sat, 16 May 2026 09:28:04 -0700 Subject: [PATCH 5/7] chore(version): bump 3.3.0 -> 3.3.1 (track upstream fastmcp v3.3.1) CMakeLists.txt 3.3.0 -> 3.3.1: track upstream fastmcp v3.3.1 (d8dcc273, 2026-05-15). README.md 2.15.0 -> 3.3.1: rectifies stale 'Current version' string left over from prior cycles; not a behavioral leap. Upstream v3.3.0b2 -> v3.3.1 delta is a pure Python import-graph refactor (#4150 "Decouple component imports from server"): moves 'fastmcp.server.auth.authorization' and 'fastmcp.server.tasks.config' into 'fastmcp.utilities.*', plus TYPE_CHECKING-only imports and function-local 'fastmcp.server.dependencies' imports to break the exposed circular imports. Plus 2 docs-only commits. No MCP protocol or C++ runtime behavior delta; no fastmcpp source changes required. Stacked on feature/parity-catchup-ee48a0fd (v3.3.0b2 catch-up). Recommended merge order: ee48a0fd first, then this branch. Post-bump verification: fastmcpp Debug ctest 103/103 GREEN. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c74687e..f000850 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.0 LANGUAGES CXX) +project(fastmcpp VERSION 3.3.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index c2dbb77..5482dc4 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:** 2.15.0 +**Current version:** 3.3.1 ## Features From d3269e27ca7bd4211912d88352e1d6af503f1b3e Mon Sep 17 00:00:00 2001 From: polaon <107108922+polaon@users.noreply.github.com> Date: Sat, 16 May 2026 10:08:26 -0700 Subject: [PATCH 6/7] fix(streamable-http): CORS headers + DELETE handler + Expose-Headers (PR #42) Merges PR #42 from @polaon. Squashed from 7 commits for clean history. Original author preserved; PR author and Copilot credited as co-authors below. Bugs fixed in src/server/streamable_http_server.cpp: 1. GET /mcp returned 405 without CORS headers (Access-Control-Allow-Origin missing). Browsers blocked the response. Fix: call apply_additional_response_headers(res) at the top of the GET handler. 2. DELETE /mcp had no handler at all. MCP Streamable HTTP spec (2025-03-26) permits DELETE for session termination, but httplib fell through to its default 404 with no CORS. Fix: register DELETE handler that erases the session from sessions_ when a valid Mcp-Session-Id is present and responds 204 No Content with CORS headers. 3. OPTIONS preflight advertised Access-Control-Allow-Methods: POST, OPTIONS only, so browsers rejected DELETE preflights pre-emptively. Fix: broaden to GET, POST, DELETE, OPTIONS. 4. Mcp-Session-Id response header not exposed to JS. Per CORS, browser JS cannot read non-safelisted headers without Access-Control-Expose-Headers. The POST handler attached Mcp-Session-Id but never set Expose-Headers, so response.headers.get('Mcp-Session-Id') returned null in browser JS. Fix: set Access-Control-Expose-Headers: Mcp-Session-Id on the POST path. 5. 401 Unauthorized + other early-return paths in the POST handler emitted responses BEFORE apply_additional_response_headers(res). Fix: move the apply_additional_response_headers call to the very top of the POST lambda, before the auth check and outside the try block, so every response path (success, 401, 503, 400, 404, 5xx via catch) carries the configured CORS / custom headers unconditionally. No public API changes. No new dependencies. SseServerWrapper not modified (same anti-pattern exists there but out of scope for this PR). CI: all 7 jobs green on PR (ubuntu/macos/windows x Debug+Release + format-check). Closes PR #42. Co-authored-by: polaon <107108922+polaon@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/server/streamable_http_server.cpp | 97 ++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/src/server/streamable_http_server.cpp b/src/server/streamable_http_server.cpp index 2f88ebb..d4f2ac9 100644 --- a/src/server/streamable_http_server.cpp +++ b/src/server/streamable_http_server.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -117,7 +116,7 @@ bool StreamableHttpServerWrapper::start() svr_->Options(mcp_path_, [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, DELETE, OPTIONS"); res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id"); apply_additional_response_headers(res); @@ -129,6 +128,17 @@ bool StreamableHttpServerWrapper::start() mcp_path_, [this](const httplib::Request& req, httplib::Response& res) { + // Apply CORS / additional headers up-front so they are present on every + // response, including early returns (401, 503, 400, 404) and any exception + // propagated to the catch handlers below. + apply_additional_response_headers(res); + + // Expose response headers that cross-origin JS clients legitimately need to + // read. Without this, browsers hide Mcp-Session-Id from response.headers.get() + // even though it is sent on the wire, because browsers only expose a small + // whitelist of "safe" response headers by default. + res.set_header("Access-Control-Expose-Headers", "Mcp-Session-Id"); + try { // Security: Check authentication if configured @@ -143,8 +153,6 @@ bool StreamableHttpServerWrapper::start() } } - apply_additional_response_headers(res); - // Parse JSON-RPC message auto message = fastmcpp::util::json::parse(req.body); @@ -335,19 +343,74 @@ bool StreamableHttpServerWrapper::start() }); // Handle GET request to return 405 Method Not Allowed - svr_->Get(mcp_path_, - [](const httplib::Request&, httplib::Response& res) - { - res.status = 405; - res.set_header("Allow", "POST"); - res.set_header("Content-Type", "application/json"); - - fastmcpp::Json error_response = { - {"error", "Method Not Allowed"}, - {"message", "The MCP endpoint only supports POST requests."}}; - - res.set_content(error_response.dump(), "application/json"); - }); + svr_->Get( + mcp_path_, + [this](const httplib::Request&, httplib::Response& res) + { + // CORS / additional headers must be applied on every response, including + // this 405. Without this, browsers reject the response with a misleading + // "No 'Access-Control-Allow-Origin' header is present" error. + apply_additional_response_headers(res); + + res.status = 405; + res.set_header("Allow", "POST, DELETE, OPTIONS"); + res.set_header("Content-Type", "application/json"); + + fastmcpp::Json error_response = { + {"error", "Method Not Allowed"}, + {"message", "The MCP endpoint only supports POST, DELETE, and OPTIONS requests."}}; + + res.set_content(error_response.dump(), "application/json"); + }); + + // Handle DELETE request for session termination (MCP Streamable HTTP spec). + // Without this handler, httplib would fall back to its default 404 response, + // which does not carry the configured CORS headers - causing browsers to report + // a "No 'Access-Control-Allow-Origin' header is present" error. + svr_->Delete(mcp_path_, + [this](const httplib::Request& req, httplib::Response& res) + { + apply_additional_response_headers(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; + } + } + + auto session_it = req.headers.find("Mcp-Session-Id"); + if (session_it == req.headers.end() || session_it->second.empty()) + { + res.status = 400; + res.set_content("{\"error\":\"Mcp-Session-Id header required\"}", + "application/json"); + return; + } + + const std::string& session_id = session_it->second; + bool did_remove = false; + { + std::lock_guard lock(sessions_mutex_); + did_remove = sessions_.erase(session_id) > 0; + } + + if (did_remove) + { + res.status = 204; // No Content + } + else + { + res.status = 404; + res.set_content("{\"error\":\"Invalid or expired session\"}", + "application/json"); + } + }); running_ = true; From 034a1ed1dd1ebb2b8d148f5c52f6f041b10e8a33 Mon Sep 17 00:00:00 2001 From: mogaitesheng <260245642+2530278940wc-dot@users.noreply.github.com> Date: Sat, 16 May 2026 10:12:44 -0700 Subject: [PATCH 7/7] feat(openapi,build): add FASTMCPP_ENABLE_OPENSSL + fix httplib::SSLClient dispatch (PR #43) Merges PR #43 from @2530278940wc-dot (mogaitesheng). Squashed from 2 commits; original author preserved; PR author and Copilot credited as co-authors below. Two changes: 1. CMakeLists.txt: new opt-in option FASTMCPP_ENABLE_OPENSSL (default OFF). When ON: find_package(OpenSSL REQUIRED), defines CPPHTTPLIB_OPENSSL_SUPPORT, links OpenSSL::SSL + OpenSSL::Crypto into fastmcpp_core. Default builds are unchanged. 2. src/providers/openapi_provider.cpp: fix a compile error that occurred when CPPHTTPLIB_OPENSSL_SUPPORT was defined. The original code stored the client in std::unique_ptr and tried to assign std::make_unique(...) into it, but httplib::Client and httplib::SSLClient do not share a base class. Split the dispatch into separate http and https branches with their own typed unique_ptr. Conflict resolution: PR #43 hardcoded 'application/json' as the content type. Our F1-F22 catch-up ( oute.request_content_type plumbing for F21 multipart/form-urlencoded) replaced that with route.request_content_type (via local variable ct). The merge preserves the dynamic content type in both http and https branches. Closes PR #43. Co-authored-by: mogaitesheng <260245642+2530278940wc-dot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 11 ++++++ src/providers/openapi_provider.cpp | 60 +++++++++++++++++++----------- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f000850..14d6c63 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ option(FASTMCPP_BUILD_EXAMPLES "Build examples" ON) option(FASTMCPP_ENABLE_POST_STREAMING "Enable POST streaming via libcurl (optional)" OFF) option(FASTMCPP_FETCH_CURL "Fetch and build libcurl statically for POST streaming" ON) option(FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS "Enable built-in OpenAI/Anthropic sampling handlers (requires libcurl)" OFF) +option(FASTMCPP_ENABLE_OPENSSL "Enable HTTPS/SSL support via OpenSSL (for cpp-httplib)" OFF) add_library(fastmcpp_core STATIC src/types.cpp @@ -97,6 +98,16 @@ if(NOT cpp_httplib_POPULATED) endif() target_include_directories(fastmcpp_core PUBLIC ${cpp_httplib_SOURCE_DIR}) +# Optional: OpenSSL for HTTPS/SSL support +if(FASTMCPP_ENABLE_OPENSSL) + find_package(OpenSSL REQUIRED) + target_compile_definitions(fastmcpp_core PUBLIC CPPHTTPLIB_OPENSSL_SUPPORT) + target_link_libraries(fastmcpp_core PUBLIC OpenSSL::SSL OpenSSL::Crypto) + message(STATUS "FASTMCPP_ENABLE_OPENSSL=ON: HTTPS/SSL support enabled") +else() + message(STATUS "FASTMCPP_ENABLE_OPENSSL=OFF: HTTPS/SSL support disabled") +endif() + # Optional: libcurl for POST streaming and sampling handlers (modular) if(FASTMCPP_ENABLE_POST_STREAMING OR FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS) if(FASTMCPP_FETCH_CURL) diff --git a/src/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp index 94593ae..1a41d13 100644 --- a/src/providers/openapi_provider.cpp +++ b/src/providers/openapi_provider.cpp @@ -511,38 +511,56 @@ Json OpenAPIProvider::invoke_route(const RouteDefinition& route, const Json& arg } } - std::unique_ptr client; + httplib::Result response; if (parsed.scheme == "http") { - client = std::make_unique(parsed.host, parsed.port); + std::unique_ptr client = + std::make_unique(parsed.host, parsed.port); + client->set_follow_location(true); + client->set_connection_timeout(30, 0); + client->set_read_timeout(30, 0); + + const auto& m = route.method; + if (m == "GET") + response = client->Get(target.c_str()); + else if (m == "POST") + response = client->Post(target.c_str(), body, ct.c_str()); + else if (m == "PUT") + response = client->Put(target.c_str(), body, ct.c_str()); + else if (m == "PATCH") + response = client->Patch(target.c_str(), body, ct.c_str()); + else if (m == "DELETE") + response = client->Delete(target.c_str(), body, ct.c_str()); + else + throw ValidationError("Unsupported OpenAPI HTTP method: " + route.method); } else { #ifdef CPPHTTPLIB_OPENSSL_SUPPORT - client = std::make_unique(parsed.host, parsed.port); + std::unique_ptr client = + std::make_unique(parsed.host, parsed.port); + client->set_follow_location(true); + client->set_connection_timeout(30, 0); + client->set_read_timeout(30, 0); + + const auto& m = route.method; + if (m == "GET") + response = client->Get(target.c_str()); + else if (m == "POST") + response = client->Post(target.c_str(), body, ct.c_str()); + else if (m == "PUT") + response = client->Put(target.c_str(), body, ct.c_str()); + else if (m == "PATCH") + response = client->Patch(target.c_str(), body, ct.c_str()); + else if (m == "DELETE") + response = client->Delete(target.c_str(), body, ct.c_str()); + else + throw ValidationError("Unsupported OpenAPI HTTP method: " + route.method); #else throw ValidationError( "OpenAPIProvider https:// requires CPPHTTPLIB_OPENSSL_SUPPORT at build time"); #endif } - client->set_follow_location(true); - client->set_connection_timeout(30, 0); - client->set_read_timeout(30, 0); - - httplib::Result response; - const auto& m = route.method; - if (m == "GET") - response = client->Get(target.c_str()); - else if (m == "POST") - response = client->Post(target.c_str(), body, ct.c_str()); - else if (m == "PUT") - response = client->Put(target.c_str(), body, ct.c_str()); - else if (m == "PATCH") - response = client->Patch(target.c_str(), body, ct.c_str()); - else if (m == "DELETE") - response = client->Delete(target.c_str(), body, ct.c_str()); - else - throw ValidationError("Unsupported OpenAPI HTTP method: " + route.method); if (!response) throw TransportError("OpenAPI HTTP request failed for " + route.method + " " + target);