Skip to content

Commit c54d21e

Browse files
authored
Merge pull request #44 from 0xeb/feature/parity-catchup-d8dcc273
Parity catch-up: v3.1.0 -> v3.3.1 + community PRs #42 + #43
2 parents 88dca2f + 034a1ed commit c54d21e

18 files changed

Lines changed: 557 additions & 78 deletions

CMakeLists.txt

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

88
set(CMAKE_CXX_STANDARD 17)
99
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -14,6 +14,7 @@ option(FASTMCPP_BUILD_EXAMPLES "Build examples" ON)
1414
option(FASTMCPP_ENABLE_POST_STREAMING "Enable POST streaming via libcurl (optional)" OFF)
1515
option(FASTMCPP_FETCH_CURL "Fetch and build libcurl statically for POST streaming" ON)
1616
option(FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS "Enable built-in OpenAI/Anthropic sampling handlers (requires libcurl)" OFF)
17+
option(FASTMCPP_ENABLE_OPENSSL "Enable HTTPS/SSL support via OpenSSL (for cpp-httplib)" OFF)
1718

1819
add_library(fastmcpp_core STATIC
1920
src/types.cpp
@@ -97,6 +98,16 @@ if(NOT cpp_httplib_POPULATED)
9798
endif()
9899
target_include_directories(fastmcpp_core PUBLIC ${cpp_httplib_SOURCE_DIR})
99100

101+
# Optional: OpenSSL for HTTPS/SSL support
102+
if(FASTMCPP_ENABLE_OPENSSL)
103+
find_package(OpenSSL REQUIRED)
104+
target_compile_definitions(fastmcpp_core PUBLIC CPPHTTPLIB_OPENSSL_SUPPORT)
105+
target_link_libraries(fastmcpp_core PUBLIC OpenSSL::SSL OpenSSL::Crypto)
106+
message(STATUS "FASTMCPP_ENABLE_OPENSSL=ON: HTTPS/SSL support enabled")
107+
else()
108+
message(STATUS "FASTMCPP_ENABLE_OPENSSL=OFF: HTTPS/SSL support disabled")
109+
endif()
110+
100111
# Optional: libcurl for POST streaming and sampling handlers (modular)
101112
if(FASTMCPP_ENABLE_POST_STREAMING OR FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS)
102113
if(FASTMCPP_FETCH_CURL)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ fastmcpp is a C++ port of the Python [fastmcp](https://github.com/jlowin/fastmcp
1515

1616
**Status:** Beta – core MCP features track the Python `fastmcp` reference.
1717

18-
**Current version:** 2.15.0
18+
**Current version:** 3.3.1
1919

2020
## Features
2121

include/fastmcpp/app.hpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,19 @@ class FastMCP
197197
return dereference_schemas_;
198198
}
199199

200+
/// Experimental capabilities advertised on `initialize`. Mirrors Python
201+
/// `FastMCP(experimental_capabilities=...)` (#4042 / commit `a010927e`):
202+
/// the dict is propagated verbatim under `capabilities.experimental` so
203+
/// servers can announce protocol extensions without a structural change.
204+
const std::optional<Json>& experimental_capabilities() const
205+
{
206+
return experimental_capabilities_;
207+
}
208+
void set_experimental_capabilities(std::optional<Json> caps)
209+
{
210+
experimental_capabilities_ = std::move(caps);
211+
}
212+
200213
// Manager accessors
201214
tools::ToolManager& tools()
202215
{
@@ -384,6 +397,7 @@ class FastMCP
384397
mutable std::vector<prompts::Prompt> provider_prompts_cache_;
385398
int list_page_size_{0};
386399
bool dereference_schemas_{true};
400+
std::optional<Json> experimental_capabilities_;
387401

388402
// Prefix utilities
389403
static std::string add_prefix(const std::string& name, const std::string& prefix);

include/fastmcpp/client/client.hpp

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,12 +1042,19 @@ class Client
10421042
CallToolResult result;
10431043
result.isError = body.value("isError", false);
10441044

1045-
if (!body.contains("content"))
1046-
throw fastmcpp::ValidationError("tools/call response missing content");
1047-
1048-
if (body.contains("content"))
1045+
// Python fastmcp commit 556fd8fa (#3778): harden client-side parsing of malformed
1046+
// tools/call results.
1047+
// - Missing "content" with isError=true is permitted (server may have raised before
1048+
// producing content); raise_on_error is handled at a higher layer using result.text().
1049+
// - Missing "content" with isError=false is treated as an empty result rather than a
1050+
// ValidationError so older servers / partial responses do not crash the client.
1051+
// - "content" present but not an array is treated as empty (do not crash).
1052+
if (body.contains("content") && body["content"].is_array())
1053+
{
10491054
for (const auto& c : body["content"])
10501055
result.content.push_back(parse_content_block(c));
1056+
}
1057+
// else: leave result.content empty
10511058

10521059
if (body.contains("structuredContent"))
10531060
{

include/fastmcpp/client/types.hpp

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,13 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t)
375375
{
376376
t.name = j.at("name").get<std::string>();
377377
if (j.contains("version"))
378-
t.version = j["version"].get<std::string>();
378+
{
379+
// Python fastmcp encodes Tool.version as a string, but historic clients may send an int.
380+
if (j["version"].is_string())
381+
t.version = j["version"].get<std::string>();
382+
else if (j["version"].is_number_integer())
383+
t.version = std::to_string(j["version"].get<long long>());
384+
}
379385
if (j.contains("title"))
380386
t.title = j["title"].get<std::string>();
381387
if (j.contains("description"))
@@ -392,6 +398,19 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t)
392398
t._meta = j["_meta"];
393399
if (j["_meta"].is_object() && j["_meta"].contains("ui") && j["_meta"]["ui"].is_object())
394400
t.app = j["_meta"]["ui"].get<fastmcpp::AppConfig>();
401+
// Python fastmcp >= 2.x exposes per-tool version via _meta.fastmcp.version (see
402+
// fastmcp_slim/fastmcp/utilities/components.py:get_meta). Surface it as ToolInfo.version
403+
// if no top-level "version" was provided so the proxy passthrough preserves the field.
404+
if (!t.version && j["_meta"].is_object() && j["_meta"].contains("fastmcp")
405+
&& j["_meta"]["fastmcp"].is_object()
406+
&& j["_meta"]["fastmcp"].contains("version"))
407+
{
408+
const auto& v = j["_meta"]["fastmcp"]["version"];
409+
if (v.is_string())
410+
t.version = v.get<std::string>();
411+
else if (v.is_number_integer())
412+
t.version = std::to_string(v.get<long long>());
413+
}
395414
}
396415
}
397416

include/fastmcpp/exceptions.hpp

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,42 @@
55
namespace fastmcpp
66
{
77

8+
/// Python `logging` module integer level constants. Mirrors Python fastmcp
9+
/// commit 73b7f2e4 (#4036) which added `FastMCPError.log_level` so
10+
/// downstream logging adapters can dispatch per-error severity. Values match
11+
/// Python `logging.{DEBUG,INFO,WARNING,ERROR,CRITICAL}`.
12+
namespace log_level
13+
{
14+
constexpr int Debug = 10;
15+
constexpr int Info = 20;
16+
constexpr int Warning = 30;
17+
constexpr int Error = 40;
18+
constexpr int Critical = 50;
19+
} // namespace log_level
20+
821
struct Error : public std::runtime_error
922
{
10-
using std::runtime_error::runtime_error;
23+
Error(const std::string& msg, int level = log_level::Error)
24+
: std::runtime_error(msg), log_level_(level)
25+
{
26+
}
27+
Error(const char* msg, int level = log_level::Error)
28+
: std::runtime_error(msg), log_level_(level)
29+
{
30+
}
31+
32+
/// Python `logging` integer level (10 Debug … 50 Critical). See `log_level::*` constants.
33+
int log_level() const noexcept
34+
{
35+
return log_level_;
36+
}
37+
void set_log_level(int level) noexcept
38+
{
39+
log_level_ = level;
40+
}
41+
42+
private:
43+
int log_level_{log_level::Error};
1144
};
1245

1346
struct NotFoundError : public Error

include/fastmcpp/providers/openapi_provider.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class OpenAPIProvider : public Provider
4141
std::vector<std::string> path_params;
4242
std::vector<std::string> query_params;
4343
bool has_json_body{false};
44+
std::string request_content_type{"application/json"};
4445
std::optional<std::string> description;
4546
};
4647

include/fastmcpp/tools/tool_transform.hpp

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,30 @@ build_transformed_schema(const Json& parent_schema,
145145
new_prop["description"] = *transform.description;
146146

147147
if (transform.type_schema.has_value())
148+
{
149+
// Python fastmcp commit b8597f94 (#4101): hoist `$defs` introduced by an
150+
// ArgTransform.type_schema to the schema root so MCP clients can resolve any
151+
// $ref the new property type references. Without this, properties using complex
152+
// annotated types reference `$defs/X` that does not exist anywhere in the
153+
// emitted schema.
154+
Json hoisted_defs = Json::object();
148155
for (auto& [k, v] : transform.type_schema->items())
156+
{
157+
if (k == "$defs" && v.is_object())
158+
{
159+
hoisted_defs = v;
160+
continue; // do not also write it under the property
161+
}
149162
new_prop[k] = v;
163+
}
164+
if (!hoisted_defs.empty())
165+
{
166+
if (!result.schema.contains("$defs") || !result.schema["$defs"].is_object())
167+
result.schema["$defs"] = Json::object();
168+
for (auto& [dk, dv] : hoisted_defs.items())
169+
result.schema["$defs"][dk] = dv;
170+
}
171+
}
150172

151173
if (transform.default_value.has_value())
152174
new_prop["default"] = *transform.default_value;
@@ -179,7 +201,20 @@ build_transformed_schema(const Json& parent_schema,
179201
}
180202

181203
// Build result schema
182-
result.schema = parent_schema;
204+
{
205+
// Preserve any $defs we hoisted above before overwriting properties/required.
206+
Json hoisted = Json::object();
207+
if (result.schema.contains("$defs") && result.schema["$defs"].is_object())
208+
hoisted = result.schema["$defs"];
209+
result.schema = parent_schema;
210+
if (!hoisted.empty())
211+
{
212+
if (!result.schema.contains("$defs") || !result.schema["$defs"].is_object())
213+
result.schema["$defs"] = Json::object();
214+
for (auto& [dk, dv] : hoisted.items())
215+
result.schema["$defs"][dk] = dv;
216+
}
217+
}
183218
result.schema["properties"] = new_properties;
184219
result.schema["required"] = Json::array();
185220
for (const auto& r : new_required)

src/client/sampling_handlers.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,28 @@ static fastmcpp::Json build_openai_messages(const fastmcpp::Json& params)
243243
fastmcpp::Json{{"role", "tool"}, {"tool_call_id", tool_use_id}, {"content", text}});
244244
}
245245

246+
// Python fastmcp commit d6b55c0b (#3857): raise on unhandled content types instead of
247+
// silently dropping. The OpenAI compatible handler currently supports text + tool_use +
248+
// tool_result; image/audio request-body conversion (F16 / commit 734b93b9) is not yet
249+
// implemented in C++, so raise a clear error rather than producing an empty assistant
250+
// message that misleads upstream code.
251+
for (const auto& block : normalize_content_to_array(content))
252+
{
253+
if (!block.is_object())
254+
continue;
255+
const std::string t = block.value("type", "");
256+
if (t == "text" || t == "tool_use" || t == "tool_result" || t.empty())
257+
continue;
258+
if (t == "image" || t == "audio")
259+
throw std::runtime_error(
260+
"OpenAI sampling handler: '" + t +
261+
"' content not yet supported (F16 / fastmcp #3550); cannot dispatch sampling "
262+
"request");
263+
// Unknown type — surface clearly so callers don't get silent data loss.
264+
throw std::runtime_error(
265+
"OpenAI sampling handler: unhandled content type '" + t + "'");
266+
}
267+
246268
std::string text = join_text_blocks(content);
247269
auto tool_uses = extract_blocks_by_type(content, "tool_use");
248270

@@ -388,6 +410,18 @@ static fastmcpp::Json build_anthropic_messages(const fastmcpp::Json& params)
388410
blocks.push_back(std::move(out));
389411
continue;
390412
}
413+
// Python fastmcp commit d6b55c0b (#3857): raise on unhandled content types.
414+
// Anthropic handler does not support audio inputs at all (Anthropic API doesn't
415+
// accept audio); image is acknowledged but request-body conversion is part of F16
416+
// (commit 734b93b9) and not yet implemented in C++.
417+
if (type == "image")
418+
throw std::runtime_error(
419+
"Anthropic sampling handler: 'image' content not yet supported "
420+
"(F16 / fastmcp #3550); cannot dispatch sampling request");
421+
if (type == "audio")
422+
throw std::runtime_error(
423+
"Anthropic sampling handler: 'audio' content is not supported by the "
424+
"Anthropic Messages API");
391425
if (type == "tool_result")
392426
{
393427
// Anthropic expects tool_use_id and string content.

src/client/transports.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,21 @@ fastmcpp::Json StdioTransport::request(const std::string& route, const fastmcpp:
471471

472472
if (keep_alive_)
473473
{
474+
// Python fastmcp commit f5804f47 (#3630): if the subprocess died between calls,
475+
// reset state and respawn on the next request rather than failing forever.
476+
if (state_)
477+
{
478+
auto exit_code = state_->process.try_wait();
479+
if (exit_code.has_value())
480+
{
481+
// Process already exited — tear down so we respawn cleanly below.
482+
state_->stderr_running.store(false, std::memory_order_release);
483+
if (state_->stderr_thread.joinable())
484+
state_->stderr_thread.join();
485+
state_.reset();
486+
}
487+
}
488+
474489
// --- Keep-alive mode: spawn once, reuse across calls ---
475490
if (!state_)
476491
{

0 commit comments

Comments
 (0)