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
22 changes: 21 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.3.1 LANGUAGES CXX)
project(fastmcpp VERSION 3.4.2 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Expand Down Expand Up @@ -267,6 +267,10 @@ if(FASTMCPP_BUILD_TESTS)
target_link_libraries(fastmcpp_schema_dereference_toggle PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_schema_dereference_toggle COMMAND fastmcpp_schema_dereference_toggle)

add_executable(fastmcpp_schema_root_ref_metadata tests/schema/root_ref_metadata.cpp)
target_link_libraries(fastmcpp_schema_root_ref_metadata PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_schema_root_ref_metadata COMMAND fastmcpp_schema_root_ref_metadata)

add_executable(fastmcpp_content tests/content.cpp)
target_link_libraries(fastmcpp_content PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_content COMMAND fastmcpp_content)
Expand Down Expand Up @@ -339,6 +343,12 @@ if(FASTMCPP_BUILD_TESTS)
add_test(NAME fastmcpp_resources_template_query_params
COMMAND fastmcpp_resources_template_query_params)

add_executable(fastmcpp_resources_template_metadata
tests/resources/template_metadata.cpp)
target_link_libraries(fastmcpp_resources_template_metadata PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_resources_template_metadata
COMMAND fastmcpp_resources_template_metadata)

add_executable(fastmcpp_server_basic tests/server/basic.cpp)
target_link_libraries(fastmcpp_server_basic PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_server_basic COMMAND fastmcpp_server_basic)
Expand Down Expand Up @@ -451,6 +461,12 @@ if(FASTMCPP_BUILD_TESTS)
target_link_libraries(fastmcpp_client_api_icons PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_client_api_icons COMMAND fastmcpp_client_api_icons)

add_executable(fastmcpp_client_tool_result_is_error
tests/client/tool_result_is_error.cpp)
target_link_libraries(fastmcpp_client_tool_result_is_error PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_client_tool_result_is_error
COMMAND fastmcpp_client_tool_result_is_error)

add_executable(fastmcpp_client_tasks tests/client/tasks.cpp)
target_link_libraries(fastmcpp_client_tasks PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_client_tasks COMMAND fastmcpp_client_tasks)
Expand Down Expand Up @@ -583,6 +599,10 @@ if(FASTMCPP_BUILD_TESTS)
target_link_libraries(fastmcpp_proxy_basic PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_proxy_basic COMMAND fastmcpp_proxy_basic)

add_executable(fastmcpp_proxy_error_strategy tests/proxy/error_strategy.cpp)
target_link_libraries(fastmcpp_proxy_error_strategy PRIVATE fastmcpp_core)
add_test(NAME fastmcpp_proxy_error_strategy COMMAND fastmcpp_proxy_error_strategy)

# Main header compile test
add_executable(fastmcpp_main_header tests/main_header/compile_test.cpp)
target_link_libraries(fastmcpp_main_header PRIVATE fastmcpp_core)
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:** 3.3.1
**Current version:** 3.4.2

## Features

Expand Down
61 changes: 61 additions & 0 deletions include/fastmcpp/proxy.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,47 @@
namespace fastmcpp
{

/// Strategy for how `ProxyApp::list_all_*` aggregations handle upstream errors.
///
/// Mirrors Python fastmcp v3.4.0 (#4227, commit 2bff3725):
/// `AggregateProvider.provider_error_strategy` and `FastMCPProxy.provider_error_strategy`.
///
/// - `Warn`: log + return locally-known items (current default; pre-v3.4.2 behavior).
/// - `Raise`: re-raise the first upstream exception so callers can fail fast
/// (use this when the proxy must surface upstream failures, e.g. behind a
/// health check or for a bridge that should not pretend the upstream is
/// present).
enum class ProxyErrorStrategy
{
Warn,
Raise,
};

/// Optional construction options for `ProxyApp` (v3.4.2 catch-up).
///
/// Use this overload to opt into the v3.4.0+ behaviors:
/// - `error_strategy = Raise` — propagate upstream errors instead of
/// silently returning local-only.
/// - `validate_on_initialize = true` — contact the upstream during the
/// inbound MCP `initialize` and surface
/// any connection failure as an
/// `INTERNAL_ERROR` JSON-RPC response,
/// instead of failing later on the
/// first `tools/list` etc. Python
/// fastmcp v3.4.0+ does this always
/// (#4228, commit 140d96aa). fastmcpp
/// keeps it opt-in for back-compat
/// with proxies whose upstream is only
/// reachable some of the time.
struct ProxyAppOptions
{
std::string name = "proxy_app";
std::string version = "1.0.0";
std::optional<std::string> instructions;
ProxyErrorStrategy error_strategy = ProxyErrorStrategy::Warn;
bool validate_on_initialize = false;
};

/// ProxyApp - An MCP server that proxies to a backend server
///
/// This class creates an MCP server that forwards requests to a backend
Expand Down Expand Up @@ -47,6 +88,24 @@ class ProxyApp
std::string version = "1.0.0",
std::optional<std::string> instructions = std::nullopt);

/// Construct proxy with v3.4.2 options (F7 error strategy + F8 initialize
/// forwarding). Pre-existing constructors continue to default to the
/// previous behavior (`Warn`, no initialize forwarding) for back-compat.
explicit ProxyApp(ClientFactory client_factory, ProxyAppOptions options);

/// Strategy applied when the upstream client throws during list_all_*.
ProxyErrorStrategy error_strategy() const
{
return error_strategy_;
}

/// When true, the MCP `initialize` request triggers an upstream
/// connection check (used by `make_mcp_handler(const ProxyApp&)`).
bool validate_on_initialize() const
{
return validate_on_initialize_;
}

// Metadata accessors
const std::string& name() const
{
Expand Down Expand Up @@ -142,6 +201,8 @@ class ProxyApp
std::string name_;
std::string version_;
std::optional<std::string> instructions_;
ProxyErrorStrategy error_strategy_{ProxyErrorStrategy::Warn};
bool validate_on_initialize_{false};
tools::ToolManager local_tools_;
resources::ResourceManager local_resources_;
prompts::PromptManager local_prompts_;
Expand Down
19 changes: 19 additions & 0 deletions src/mcp/handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2581,6 +2581,25 @@ std::function<fastmcpp::Json(const fastmcpp::Json&)> make_mcp_handler(const Prox
{
fastmcpp::Json serverInfo = {{"name", app.name()}, {"version", app.version()}};

// F8 (Python fastmcp #4228 / 140d96aa): when configured,
// forward the inbound MCP initialize to the upstream so an
// unreachable backend surfaces as a JSON-RPC error here
// instead of failing later on the first tools/list. fastmcpp
// keeps this opt-in via ProxyAppOptions::validate_on_initialize
// (Python made it always-on; we preserve back-compat).
if (app.validate_on_initialize())
{
try
{
auto upstream = app.get_client();
upstream.initialize();
}
catch (const std::exception& e)
{
return jsonrpc_error(id, kJsonRpcInternalError, e.what());
}
}

// Advertise capabilities
fastmcpp::Json capabilities = {{"tools", fastmcpp::Json::object()}};
if (!app.list_all_resources().empty() || !app.list_all_resource_templates().empty())
Expand Down
14 changes: 13 additions & 1 deletion src/providers/transforms/version_filter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,23 @@ std::string strip_leading_zeros(const std::string& s)
return s.substr(i);
}

// Strip a leading 'v' or 'V' prefix when followed by a digit so versions
// like "v1.2" compare equal to "1.2". Mirrors Python `parse_version_key`
// behavior in fastmcp v3.4.0 (#4058 / commit 24b594b1).
std::string strip_v_prefix(const std::string& version)
{
if (version.size() >= 2 && (version[0] == 'v' || version[0] == 'V') &&
std::isdigit(static_cast<unsigned char>(version[1])))
return version.substr(1);
return version;
}

std::vector<std::string> split_version(const std::string& version)
{
const std::string normalized = strip_v_prefix(version);
std::vector<std::string> parts;
std::string current;
for (char c : version)
for (char c : normalized)
{
if (c == '.' || c == '-' || c == '_')
{
Expand Down
23 changes: 21 additions & 2 deletions src/proxy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ ProxyApp::ProxyApp(ClientFactory client_factory, std::string name, std::string v
{
}

ProxyApp::ProxyApp(ClientFactory client_factory, ProxyAppOptions options)
: client_factory_(std::move(client_factory)), name_(std::move(options.name)),
version_(std::move(options.version)), instructions_(std::move(options.instructions)),
error_strategy_(options.error_strategy),
validate_on_initialize_(options.validate_on_initialize)
{
}

// =========================================================================
// Conversion Helpers
// =========================================================================
Expand Down Expand Up @@ -121,10 +129,15 @@ std::vector<client::ToolInfo> ProxyApp::list_all_tools() const
}
catch (const std::exception& e)
{
// Surface bad URL/transport; otherwise fall back to local-only
// Surface bad URL/transport; otherwise apply error strategy.
if (dynamic_cast<const std::invalid_argument*>(&e))
throw;
// Remote not available, continue with local only
// F7 (Python fastmcp #4227 / 2bff3725): when configured to raise,
// propagate upstream failures so the caller can fail fast instead of
// silently degrading to local-only.
if (error_strategy_ == ProxyErrorStrategy::Raise)
throw;
// Default (Warn): remote not available, continue with local only.
}

return result;
Expand Down Expand Up @@ -156,6 +169,8 @@ std::vector<client::ResourceInfo> ProxyApp::list_all_resources() const
{
if (dynamic_cast<const std::invalid_argument*>(&e))
throw;
if (error_strategy_ == ProxyErrorStrategy::Raise)
throw;
// Remote not available
}

Expand Down Expand Up @@ -188,6 +203,8 @@ std::vector<client::ResourceTemplate> ProxyApp::list_all_resource_templates() co
{
if (dynamic_cast<const std::invalid_argument*>(&e))
throw;
if (error_strategy_ == ProxyErrorStrategy::Raise)
throw;
// Remote not available
}

Expand Down Expand Up @@ -220,6 +237,8 @@ std::vector<client::PromptInfo> ProxyApp::list_all_prompts() const
{
if (dynamic_cast<const std::invalid_argument*>(&e))
throw;
if (error_strategy_ == ProxyErrorStrategy::Raise)
throw;
// Remote not available
}

Expand Down
9 changes: 9 additions & 0 deletions src/resources/template.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,15 @@ ResourceTemplate::create_resource(const std::string& uri,
resource.mime_type = mime_type;
resource.app = app;

// Mirror Python fastmcp #4061 (commit 01b971d8): preserve template-level
// metadata so resources instantiated from a template carry the template's
// display annotations rather than appearing unstyled/unannotated.
resource.title = title;
resource.annotations = annotations;
resource.icons = icons;
resource.version = version;
resource.task_support = task_support;

// Create a provider that captures the extracted params and delegates to the template provider
if (provider)
{
Expand Down
112 changes: 112 additions & 0 deletions tests/client/tool_result_is_error.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// F5 — Python fastmcp #4217 (commit e242abee): ToolResult is_error
//
// The Python change introduced ToolResult.is_error so a tool can RETURN an
// error result (instead of raising). fastmcpp already supports this at the
// wire level: a server-side tool returning {"content": [...], "isError": true}
// surfaces through the JSON-RPC response, and the client's call_tool() with
// raise_on_error=false returns a CallToolResult with isError=true.
//
// This test pins that contract explicitly:
// - server returns an isError=true result with structured + text content
// - client.call_tool(name, args, ..., raise_on_error=false) MUST NOT throw
// - returned CallToolResult.isError == true
// - content + structuredContent are preserved (no collapse to "Tool call failed")
//
// Prior coverage (interactions_part1.cpp `test_call_tool_error_handling`) only
// validated the default raise_on_error=true (throw) path.

#include "fastmcpp/client/client.hpp"
#include "fastmcpp/server/server.hpp"

#include <cassert>
#include <iostream>
#include <variant>

using namespace fastmcpp;

namespace
{
std::shared_ptr<server::Server> make_error_server()
{
auto srv = std::make_shared<server::Server>();

srv->route(
"tools/list",
[](const Json&)
{
Json tools = Json::array();
tools.push_back(Json{{"name", "always_error"},
{"description", "Returns is_error=true with custom content"},
{"inputSchema", Json{{"type", "object"}}}});
return Json{{"tools", tools}};
});

srv->route(
"tools/call",
[](const Json& in)
{
std::string name = in.at("name").get<std::string>();
if (name == "always_error")
{
// Server returns isError=true with structured + non-trivial content.
return Json{
{"content", Json::array({Json{{"type", "text"},
{"text", "validation failed: bad value"}}})},
{"structuredContent", Json{{"errors", Json::array({"bad value"})}}},
{"isError", true}};
}
return Json{{"content", Json::array()}, {"isError", false}};
});

return srv;
}
} // namespace

int main()
{
auto srv = make_error_server();

// 1) raise_on_error=true (default) MUST throw on isError=true.
{
client::Client c(std::make_unique<client::LoopbackTransport>(srv));
bool threw = false;
try
{
c.call_tool("always_error", Json::object());
}
catch (const fastmcpp::Error&)
{
threw = true;
}
assert(threw && "default raise_on_error must throw on isError=true");
}

// 2) F5: raise_on_error=false MUST return the result with isError=true.
{
client::Client c(std::make_unique<client::LoopbackTransport>(srv));
auto result =
c.call_tool("always_error", Json::object(), std::nullopt,
std::chrono::milliseconds{0}, nullptr, /*raise_on_error=*/false);
assert(result.isError && "F5: isError must propagate when raise_on_error=false");

// 3) content preserved (NOT collapsed to a generic 'Tool call failed').
assert(!result.content.empty() && "F5: content must be preserved");
bool found_text = false;
for (const auto& block : result.content)
{
if (const auto* tc = std::get_if<client::TextContent>(&block))
{
if (tc->text.find("validation failed") != std::string::npos)
found_text = true;
}
}
assert(found_text && "F5: original error text must be preserved");

// 4) structuredContent preserved too.
assert(result.structuredContent.has_value() && "F5: structuredContent preserved");
assert(result.structuredContent->contains("errors") && "F5: structured errors preserved");
}

std::cout << "PASS: ToolResult is_error wire-level passthrough (F5)" << std::endl;
return 0;
}
Loading
Loading