diff --git a/CMakeLists.txt b/CMakeLists.txt index e0e6878..14d6c63 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.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -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/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 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/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/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/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/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/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/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/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp index 248bc81..1a41d13 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"); @@ -268,6 +298,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"]; @@ -319,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"); } } @@ -388,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(); @@ -403,41 +483,84 @@ 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; + 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, "application/json"); - else if (m == "PUT") - response = client->Put(target.c_str(), body, "application/json"); - else if (m == "PATCH") - response = client->Patch(target.c_str(), body, "application/json"); - else if (m == "DELETE") - response = client->Delete(target.c_str(), body, "application/json"); - else - throw ValidationError("Unsupported OpenAPI HTTP method: " + route.method); if (!response) throw TransportError("OpenAPI HTTP request failed for " + route.method + " " + target); 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()); } } 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..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"); + 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); - fastmcpp::Json error_response = { - {"error", "Method Not Allowed"}, - {"message", "The MCP endpoint only supports POST requests."}}; + res.status = 405; + res.set_header("Allow", "POST, DELETE, OPTIONS"); + res.set_header("Content-Type", "application/json"); - res.set_content(error_response.dump(), "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; @@ -383,17 +446,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. } 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()) { 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"; }