Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions include/fastmcpp/app.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Json>& experimental_capabilities() const
{
return experimental_capabilities_;
}
void set_experimental_capabilities(std::optional<Json> caps)
{
experimental_capabilities_ = std::move(caps);
}

// Manager accessors
tools::ToolManager& tools()
{
Expand Down Expand Up @@ -384,6 +397,7 @@ class FastMCP
mutable std::vector<prompts::Prompt> provider_prompts_cache_;
int list_page_size_{0};
bool dereference_schemas_{true};
std::optional<Json> experimental_capabilities_;

// Prefix utilities
static std::string add_prefix(const std::string& name, const std::string& prefix);
Expand Down
15 changes: 11 additions & 4 deletions include/fastmcpp/client/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
{
Expand Down
21 changes: 20 additions & 1 deletion include/fastmcpp/client/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,13 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t)
{
t.name = j.at("name").get<std::string>();
if (j.contains("version"))
t.version = j["version"].get<std::string>();
{
// 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<std::string>();
else if (j["version"].is_number_integer())
t.version = std::to_string(j["version"].get<long long>());
}
if (j.contains("title"))
t.title = j["title"].get<std::string>();
if (j.contains("description"))
Expand All @@ -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<fastmcpp::AppConfig>();
// 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<std::string>();
else if (v.is_number_integer())
t.version = std::to_string(v.get<long long>());
}
}
}

Expand Down
35 changes: 34 additions & 1 deletion include/fastmcpp/exceptions.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions include/fastmcpp/providers/openapi_provider.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class OpenAPIProvider : public Provider
std::vector<std::string> path_params;
std::vector<std::string> query_params;
bool has_json_body{false};
std::string request_content_type{"application/json"};
std::optional<std::string> description;
};

Expand Down
37 changes: 36 additions & 1 deletion include/fastmcpp/tools/tool_transform.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions src/client/sampling_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/client/transports.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_)
{
Expand Down
16 changes: 16 additions & 0 deletions src/mcp/handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ static void attach_meta_ui(fastmcpp::Json& entry, const std::optional<fastmcpp::
entry["_meta"] = std::move(merged);
}

// Inject FastMCP::experimental_capabilities() into the `capabilities` block on `initialize`.
// Mirrors Python `FastMCP(experimental_capabilities=...)` (#4042 / commit a010927e):
// the dict is propagated verbatim so servers can advertise protocol extensions.
static void advertise_experimental(fastmcpp::Json& capabilities, const FastMCP& app)
{
const auto& exp = app.experimental_capabilities();
if (exp && exp->is_object() && !exp->empty())
capabilities["experimental"] = *exp;
}

static std::string normalize_resource_uri(std::string uri)
{
while (uri.size() > 1 && !uri.empty() && uri.back() == '/')
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -2577,6 +2588,8 @@ std::function<fastmcpp::Json(const fastmcpp::Json&)> 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},
Expand All @@ -2600,6 +2613,8 @@ std::function<fastmcpp::Json(const fastmcpp::Json&)> 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)
Expand Down Expand Up @@ -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},
Expand Down
Loading
Loading