diff --git a/CMakeLists.txt b/CMakeLists.txt index d8b016e..30b652e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,11 +8,11 @@ set(CMAKE_CXX_EXTENSIONS OFF) option(FASTMCPP_BUILD_TESTS "Build tests" ON) option(FASTMCPP_BUILD_EXAMPLES "Build examples" ON) option(FASTMCPP_ENABLE_POST_STREAMING "Enable POST streaming via libcurl (optional)" OFF) -option(FASTMCPP_FETCH_CURL "Attempt to fetch libcurl if not found (experimental)" OFF) +option(FASTMCPP_FETCH_CURL "Fetch and build libcurl statically for POST streaming" ON) option(FASTMCPP_ENABLE_WS_STREAMING_TESTS "Enable WebSocket streaming tests (requires external server)" OFF) option(FASTMCPP_ENABLE_LOCAL_WS_TEST "Enable local WebSocket server test (depends on httplib ws server support)" OFF) -add_library(fastmcpp_core +add_library(fastmcpp_core STATIC src/types.cpp src/util/schema_build.cpp src/app.cpp @@ -96,11 +96,61 @@ target_sources(fastmcpp_core PRIVATE ${easywsclient_SOURCE_DIR}/easywsclient.cpp # Optional: libcurl for POST streaming receive support (modular) if(FASTMCPP_ENABLE_POST_STREAMING) - find_package(CURL) - if(NOT CURL_FOUND AND FASTMCPP_FETCH_CURL) - message(STATUS "CURL not found; FASTMCPP_FETCH_CURL requested (skipping auto-fetch in this build). Please provide CURL via your toolchain or package manager.") + if(FASTMCPP_FETCH_CURL) + message(STATUS "FASTMCPP_FETCH_CURL=ON: fetching curl via FetchContent (static-only)") + include(FetchContent) + + # Configure curl for a minimal library build. + set(BUILD_CURL_EXE OFF CACHE BOOL "Build curl executable" FORCE) + set(CURL_DISABLE_TESTS ON CACHE BOOL "Disable curl tests" FORCE) + set(CURL_DISABLE_INSTALL ON CACHE BOOL "Disable curl install targets" FORCE) + set(CURL_DISABLE_LDAP ON CACHE BOOL "Disable LDAP support" FORCE) + + # Prefer platform TLS backends to avoid OpenSSL as a dependency. + if(WIN32) + set(CURL_USE_SCHANNEL ON CACHE BOOL "Use Windows Schannel for TLS" FORCE) + set(CURL_USE_OPENSSL OFF CACHE BOOL "Do not use OpenSSL" FORCE) + endif() + + # Always build curl statically to avoid runtime DLL dependencies. + if(DEFINED BUILD_SHARED_LIBS) + set(_fastmcpp_prev_build_shared_libs "${BUILD_SHARED_LIBS}") + else() + set(_fastmcpp_prev_build_shared_libs "__UNDEFINED__") + endif() + set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build shared libraries" FORCE) + + FetchContent_Declare( + curl + GIT_REPOSITORY https://github.com/curl/curl.git + GIT_TAG curl-8_9_1 + ) + FetchContent_MakeAvailable(curl) + + if(TARGET libcurl_static AND NOT TARGET CURL::libcurl) + add_library(CURL::libcurl ALIAS libcurl_static) + endif() + + if(_fastmcpp_prev_build_shared_libs STREQUAL "__UNDEFINED__") + unset(BUILD_SHARED_LIBS CACHE) + else() + set(BUILD_SHARED_LIBS "${_fastmcpp_prev_build_shared_libs}" CACHE BOOL "Build shared libraries" FORCE) + endif() + else() + # Best effort for users who provide a curl toolchain: request static linkage. + set(CURL_USE_STATIC_LIBS ON) + find_package(CURL QUIET) + + if(CURL_FOUND AND NOT TARGET CURL::libcurl) + add_library(CURL::libcurl UNKNOWN IMPORTED) + set_target_properties(CURL::libcurl PROPERTIES + IMPORTED_LOCATION "${CURL_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${CURL_INCLUDE_DIRS}" + ) + endif() endif() - if(CURL_FOUND) + + if(TARGET CURL::libcurl) target_compile_definitions(fastmcpp_core PRIVATE FASTMCPP_POST_STREAMING) target_link_libraries(fastmcpp_core PRIVATE CURL::libcurl) else() @@ -414,18 +464,26 @@ if(FASTMCPP_BUILD_TESTS) endif() if(FASTMCPP_ENABLE_WS_STREAMING_TESTS) - add_executable(fastmcpp_ws_streaming tests/transports/ws_streaming.cpp) + add_executable(fastmcpp_ws_streaming tests/transports/ws_streaming.cpp) target_link_libraries(fastmcpp_ws_streaming PRIVATE fastmcpp_core) add_test(NAME fastmcpp_ws_streaming COMMAND fastmcpp_ws_streaming) # Test auto-skips if FASTMCPP_WS_URL is not set if(FASTMCPP_ENABLE_LOCAL_WS_TEST) add_executable(fastmcpp_ws_streaming_local tests/transports/ws_streaming_local.cpp) - target_link_libraries(fastmcpp_ws_streaming_local PRIVATE fastmcpp_core) + target_link_libraries(fastmcpp_ws_streaming_local PRIVATE fastmcpp_core) add_test(NAME fastmcpp_ws_streaming_local COMMAND fastmcpp_ws_streaming_local) set_tests_properties(fastmcpp_ws_streaming_local PROPERTIES RUN_SERIAL TRUE) endif() endif() + + # POST streaming transport test (requires libcurl) + if(FASTMCPP_ENABLE_POST_STREAMING AND TARGET CURL::libcurl) + add_executable(fastmcpp_post_streaming tests/transports/post_streaming.cpp) + target_link_libraries(fastmcpp_post_streaming PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_post_streaming COMMAND fastmcpp_post_streaming) + set_tests_properties(fastmcpp_post_streaming PROPERTIES RUN_SERIAL TRUE) + endif() endif() if(FASTMCPP_BUILD_EXAMPLES) @@ -506,7 +564,7 @@ if(FASTMCPP_BUILD_EXAMPLES) if(FASTMCPP_ENABLE_POST_STREAMING) add_executable(fastmcpp_example_streaming_post_demo examples/streaming_post_demo.cpp) target_link_libraries(fastmcpp_example_streaming_post_demo PRIVATE fastmcpp_core) - if(CURL_FOUND) + if(TARGET CURL::libcurl) add_test(NAME fastmcpp_example_streaming_post_demo COMMAND fastmcpp_example_streaming_post_demo) else() message(STATUS "libcurl not found; skipping test fastmcpp_example_streaming_post_demo") diff --git a/README.md b/README.md index 8b20245..567fb10 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.14.0 +**Current version:** 2.14.1 ## Features @@ -42,7 +42,7 @@ fastmcpp is a C++ port of the Python [fastmcp](https://github.com/jlowin/fastmcp Optional: -- libcurl (for HTTP POST streaming). +- libcurl (for HTTP POST streaming; can be fetched when `FASTMCPP_FETCH_CURL=ON`). - cpp‑httplib (HTTP server, fetched automatically). - easywsclient (WebSocket client, fetched automatically). @@ -75,7 +75,7 @@ Key options: |----------------------------------|---------|--------------------------------------------------| | `CMAKE_BUILD_TYPE` | Debug | Build configuration (Debug/Release/RelWithDebInfo) | | `FASTMCPP_ENABLE_POST_STREAMING` | OFF | Enable HTTP POST streaming (requires libcurl) | -| `FASTMCPP_FETCH_CURL` | OFF | Fetch and build curl if not found | +| `FASTMCPP_FETCH_CURL` | OFF | Fetch and build curl (via FetchContent) if not found | | `FASTMCPP_ENABLE_STREAMING_TESTS` | OFF | Enable SSE streaming tests | | `FASTMCPP_ENABLE_WS_STREAMING_TESTS` | OFF | Enable WebSocket streaming tests | diff --git a/include/fastmcpp/client/client.hpp b/include/fastmcpp/client/client.hpp index 22e29d1..e4e8646 100644 --- a/include/fastmcpp/client/client.hpp +++ b/include/fastmcpp/client/client.hpp @@ -59,7 +59,8 @@ class IResettableTransport using ServerRequestHandler = std::function; -/// Optional transport interface: some transports can accept server-initiated requests and send responses. +/// Optional transport interface: some transports can accept server-initiated requests and send +/// responses. class IServerRequestTransport { public: @@ -663,7 +664,7 @@ class Client } /// Register roots/sampling/elicitation callbacks (placeholders for parity) - void set_roots_callback(const std::function& cb) + void set_roots_callback(const std::function& cb) { set_roots_callback_impl(cb); } @@ -714,7 +715,7 @@ class Client }; std::shared_ptr callbacks_; - std::unordered_map tool_output_schemas_; + std::unordered_map tool_output_schemas_; std::function get_roots_callback() const { @@ -745,16 +746,15 @@ class Client std::lock_guard lock(callbacks_->mutex); callbacks_->roots_callback = cb; } - void set_sampling_callback_impl( - const std::function& cb) + void set_sampling_callback_impl(const std::function& cb) { if (!callbacks_) callbacks_ = std::make_shared(); std::lock_guard lock(callbacks_->mutex); callbacks_->sampling_callback = cb; } - void set_elicitation_callback_impl( - const std::function& cb) + void + set_elicitation_callback_impl(const std::function& cb) { if (!callbacks_) callbacks_ = std::make_shared(); @@ -811,7 +811,8 @@ class Client } // Internal constructor for cloning - Client(std::shared_ptr t, std::shared_ptr callbacks, bool /*internal*/) + Client(std::shared_ptr t, std::shared_ptr callbacks, + bool /*internal*/) : transport_(std::move(t)), callbacks_(std::move(callbacks)) { configure_transport_callbacks(); diff --git a/include/fastmcpp/client/sampling.hpp b/include/fastmcpp/client/sampling.hpp index 4c8d940..6ff344d 100644 --- a/include/fastmcpp/client/sampling.hpp +++ b/include/fastmcpp/client/sampling.hpp @@ -22,15 +22,14 @@ using SamplingHandlerResult = std::variant; using SamplingHandler = std::function; /// Build a minimal MCP CreateMessageResult with a single text content block. -inline fastmcpp::Json make_text_result(std::string text, - std::string model = "fastmcpp-client", - std::string role = "assistant") +inline fastmcpp::Json make_text_result(std::string text, std::string model = "fastmcpp-client", + std::string role = "assistant") { return fastmcpp::Json{ {"role", std::move(role)}, {"model", std::move(model)}, - {"content", fastmcpp::Json::array( - {fastmcpp::Json{{"type", "text"}, {"text", std::move(text)}}})}, + {"content", + fastmcpp::Json::array({fastmcpp::Json{{"type", "text"}, {"text", std::move(text)}}})}, }; } @@ -49,4 +48,3 @@ create_sampling_callback(SamplingHandler handler) } } // namespace fastmcpp::client::sampling - diff --git a/include/fastmcpp/client/transports.hpp b/include/fastmcpp/client/transports.hpp index c14f6e4..3f7b751 100644 --- a/include/fastmcpp/client/transports.hpp +++ b/include/fastmcpp/client/transports.hpp @@ -96,7 +96,9 @@ class StdioTransport : public ITransport /// 1. Client connects to /sse endpoint (GET) to establish event stream /// 2. Client sends JSON-RPC requests to /messages endpoint (POST) /// 3. Server sends JSON-RPC responses back via the SSE stream -class SseClientTransport : public ITransport, public IServerRequestTransport, public IResettableTransport +class SseClientTransport : public ITransport, + public IServerRequestTransport, + public IResettableTransport { public: /// Construct an SSE client transport @@ -121,7 +123,7 @@ class SseClientTransport : public ITransport, public IServerRequestTransport, pu /// Check if a session ID has been set. bool has_session() const; - void set_server_request_handler(ServerRequestHandler handler) override; + void set_server_request_handler(ServerRequestHandler handler) override; void reset(bool full = false) override; diff --git a/include/fastmcpp/client/types.hpp b/include/fastmcpp/client/types.hpp index 04aa9fc..b765e6d 100644 --- a/include/fastmcpp/client/types.hpp +++ b/include/fastmcpp/client/types.hpp @@ -343,7 +343,7 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t) t.title = j["title"].get(); if (j.contains("description")) t.description = j["description"].get(); - t.inputSchema = j.value("inputSchema", fastmcpp::Json::object()); + t.inputSchema = j.value("inputSchema", fastmcpp::Json::object()); if (j.contains("outputSchema")) t.outputSchema = j["outputSchema"]; if (j.contains("execution")) @@ -389,7 +389,7 @@ inline void from_json(const fastmcpp::Json& j, ResourceInfo& r) r._meta = j["_meta"]; } -inline void to_json(fastmcpp::Json& j, const ResourceTemplate& t) +inline void to_json(fastmcpp::Json& j, const ResourceTemplate& t) { j = fastmcpp::Json{{"uriTemplate", t.uriTemplate}, {"name", t.name}}; if (t.title) @@ -406,7 +406,7 @@ inline void to_json(fastmcpp::Json& j, const ResourceTemplate& t) j["_meta"] = *t._meta; } -inline void from_json(const fastmcpp::Json& j, ResourceTemplate& t) +inline void from_json(const fastmcpp::Json& j, ResourceTemplate& t) { t.uriTemplate = j.at("uriTemplate").get(); t.name = j.at("name").get(); diff --git a/src/client/transports.cpp b/src/client/transports.cpp index 5392896..548e03e 100644 --- a/src/client/transports.cpp +++ b/src/client/transports.cpp @@ -295,6 +295,8 @@ void HttpTransport::request_stream_post(const std::string& route, const fastmcpp throw fastmcpp::TransportError("libcurl init failed"); std::string url = base_url_; + if (url.find("://") == std::string::npos) + url = "http://" + url; if (!url.empty() && url.back() != '/') url.push_back('/'); url += route; @@ -403,7 +405,7 @@ void HttpTransport::request_stream_post(const std::string& route, const fastmcpp // Parse whatever accumulated parse_and_emit(true); - if (code != CURLE_OK) + if (code != CURLE_OK && code != CURLE_PARTIAL_FILE) { throw fastmcpp::TransportError(std::string("HTTP stream POST failed: ") + curl_easy_strerror(code)); @@ -706,7 +708,7 @@ void SseClientTransport::start_sse_listener() if (!aggregated.empty()) { - // Handle endpoint event specially - it's not JSON + // Handle endpoint event specially - it's not JSON if (event_type == "endpoint") { std::lock_guard lock(endpoint_mutex_); @@ -719,9 +721,9 @@ void SseClientTransport::start_sse_listener() { pos += std::string("session_id=").size(); auto end = endpoint_path_.find_first_of("&#", pos); - session_id_ = endpoint_path_.substr( - pos, end == std::string::npos ? std::string::npos - : (end - pos)); + session_id_ = endpoint_path_.substr(pos, end == std::string::npos + ? std::string::npos + : (end - pos)); } } else @@ -917,7 +919,7 @@ fastmcpp::Json SseClientTransport::request(const std::string& route, const fastm cli.set_connection_timeout(5, 0); cli.set_read_timeout(30, 0); - // Use the endpoint path from SSE if available, otherwise use default + // Use the endpoint path from SSE if available, otherwise use default std::string post_path; { std::lock_guard lock(endpoint_mutex_); diff --git a/tests/server/interactions_part2b.cpp b/tests/server/interactions_part2b.cpp index 673ccfd..2acfe22 100644 --- a/tests/server/interactions_part2b.cpp +++ b/tests/server/interactions_part2b.cpp @@ -904,7 +904,7 @@ void test_tool_meta_custom_fields() std::cout << "Test: tool list with meta fields...\n"; auto srv = create_meta_variations_server(); - client::Client c(std::make_unique(srv)); + client::Client c(std::make_unique(srv)); // Test that list_tools_mcp can access list-level _meta auto result = c.list_tools_mcp(); @@ -959,7 +959,7 @@ void test_resource_meta_fields() std::cout << "Test: resource with meta fields...\n"; auto srv = create_meta_variations_server(); - client::Client c(std::make_unique(srv)); + client::Client c(std::make_unique(srv)); auto resources = c.list_resources(); bool found = false; diff --git a/tests/server/sse_bidirectional_requests.cpp b/tests/server/sse_bidirectional_requests.cpp index 4ebdf70..bd02c57 100644 --- a/tests/server/sse_bidirectional_requests.cpp +++ b/tests/server/sse_bidirectional_requests.cpp @@ -28,7 +28,9 @@ Json make_result_response(const Json& request_id, Json result) Json make_error_response(const Json& request_id, int code, const std::string& message) { - return Json{{"jsonrpc", "2.0"}, {"id", request_id}, {"error", Json{{"code", code}, {"message", message}}}}; + return Json{{"jsonrpc", "2.0"}, + {"id", request_id}, + {"error", Json{{"code", code}, {"message", message}}}}; } } // namespace @@ -101,8 +103,8 @@ int main() std::this_thread::sleep_for(500ms); - auto transport = std::make_unique( - "http://127.0.0.1:" + std::to_string(port)); + auto transport = std::make_unique("http://127.0.0.1:" + + std::to_string(port)); auto* sse_transport = transport.get(); fastmcpp::client::Client client(std::move(transport)); @@ -178,7 +180,8 @@ int main() Json result; try { - result = session->send_request("sampling/createMessage", params, std::chrono::milliseconds(5000)); + result = session->send_request("sampling/createMessage", params, + std::chrono::milliseconds(5000)); } catch (const std::exception& e) { @@ -201,7 +204,8 @@ int main() } if (result.value("model", std::string()) != "fastmcpp-client") { - std::cerr << "Unexpected model in sampling response: " << result.value("model", std::string()) << "\n"; + std::cerr << "Unexpected model in sampling response: " + << result.value("model", std::string()) << "\n"; sse_server->stop(); return 1; } diff --git a/tests/server/streamable_http_integration.cpp b/tests/server/streamable_http_integration.cpp index c23c1a8..5b88c4d 100644 --- a/tests/server/streamable_http_integration.cpp +++ b/tests/server/streamable_http_integration.cpp @@ -238,7 +238,8 @@ void test_session_management() transport.request("initialize", init_params); assert(transport.has_session() && "Should have session after re-initialize"); assert(transport.session_id() != session_id && "Session ID should change after reset"); - assert(server.session_count() == 2 && "Server should have 2 sessions after reset + initialize"); + assert(server.session_count() == 2 && + "Server should have 2 sessions after reset + initialize"); std::cout << "PASSED\n"; } diff --git a/tests/transports/post_streaming.cpp b/tests/transports/post_streaming.cpp new file mode 100644 index 0000000..573e542 --- /dev/null +++ b/tests/transports/post_streaming.cpp @@ -0,0 +1,100 @@ +#include "fastmcpp/client/transports.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +int main() +{ + using fastmcpp::Json; + using fastmcpp::client::HttpTransport; + + httplib::Server svr; + std::atomic ready{false}; + + svr.Post("/sse", + [&](const httplib::Request& req, httplib::Response& res) + { + if (req.body.find("\"hello\"") == std::string::npos) + { + res.status = 400; + res.set_content("bad request", "text/plain"); + return; + } + + res.set_chunked_content_provider( + "text/event-stream", + [&](size_t /*offset*/, httplib::DataSink& sink) + { + const std::string e1 = "data: {\"n\":1}\n\n"; + sink.write(e1.data(), e1.size()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + const std::string e2a = "data: {\"n\":\n"; + const std::string e2b = "data: 2}\n\n"; + sink.write(e2a.data(), e2a.size()); + sink.write(e2b.data(), e2b.size()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + const std::string e3 = "data: hello\n\n"; + sink.write(e3.data(), e3.size()); + return false; // end stream + }, + [](bool) {}); + }); + + const int port = svr.bind_to_any_port("127.0.0.1"); + if (port <= 0) + { + std::cerr << "Failed to bind server\n"; + return 1; + } + + std::thread th( + [&]() + { + ready.store(true); + svr.listen_after_bind(); + }); + + while (!ready.load()) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + svr.wait_until_ready(); + + std::vector events; + try + { + HttpTransport http("127.0.0.1:" + std::to_string(port)); + http.request_stream_post("sse", Json{{"hello", "world"}}, + [&](const Json& evt) { events.push_back(evt); }); + } + catch (const std::exception& e) + { + std::cerr << "POST streaming failed: " << e.what() << "\n"; + svr.stop(); + if (th.joinable()) + th.join(); + return 1; + } + + svr.stop(); + if (th.joinable()) + th.join(); + + assert(events.size() == 3); + assert(events[0].contains("n") && events[0]["n"].get() == 1); + assert(events[1].contains("n") && events[1]["n"].get() == 2); + assert(events[2].contains("content")); + assert(events[2]["content"].is_array()); + assert(!events[2]["content"].empty()); + assert(events[2]["content"][0].value("type", std::string()) == "text"); + assert(events[2]["content"][0].value("text", std::string()) == "hello"); + + std::cout << "ok\n"; + return 0; +}