diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c762df0..a9641f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: - name: Build (Unix) if: runner.os != 'Windows' - run: cmake --build build --config ${{ matrix.build_type }} --parallel + run: cmake --build build --config ${{ matrix.build_type }} --parallel 2 - name: Build (Windows) if: runner.os == 'Windows' diff --git a/CMakeLists.txt b/CMakeLists.txt index 30b652e..82b76b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,7 @@ 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 "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_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) @@ -37,6 +38,7 @@ add_library(fastmcpp_core STATIC src/server/sse_server.cpp src/server/streamable_http_server.cpp src/client/client.cpp + src/client/sampling_handlers.cpp src/client/transports.cpp src/util/json_schema.cpp src/util/json_schema_type.cpp @@ -94,8 +96,8 @@ endif() target_include_directories(fastmcpp_core PUBLIC ${easywsclient_SOURCE_DIR}) target_sources(fastmcpp_core PRIVATE ${easywsclient_SOURCE_DIR}/easywsclient.cpp) -# Optional: libcurl for POST streaming receive support (modular) -if(FASTMCPP_ENABLE_POST_STREAMING) +# Optional: libcurl for POST streaming and sampling handlers (modular) +if(FASTMCPP_ENABLE_POST_STREAMING OR FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS) if(FASTMCPP_FETCH_CURL) message(STATUS "FASTMCPP_FETCH_CURL=ON: fetching curl via FetchContent (static-only)") include(FetchContent) @@ -151,10 +153,18 @@ if(FASTMCPP_ENABLE_POST_STREAMING) endif() if(TARGET CURL::libcurl) - target_compile_definitions(fastmcpp_core PRIVATE FASTMCPP_POST_STREAMING) target_link_libraries(fastmcpp_core PRIVATE CURL::libcurl) + target_compile_definitions(fastmcpp_core PRIVATE FASTMCPP_HAS_CURL) + if(FASTMCPP_ENABLE_POST_STREAMING) + target_compile_definitions(fastmcpp_core PRIVATE FASTMCPP_POST_STREAMING) + endif() else() - message(STATUS "libcurl not found; POST streaming will be disabled at runtime (request_stream_post throws)") + if(FASTMCPP_ENABLE_POST_STREAMING) + message(STATUS "libcurl not found; POST streaming will be disabled at runtime (request_stream_post throws)") + endif() + if(FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS) + message(STATUS "libcurl not found; built-in sampling handlers will be unavailable") + endif() endif() endif() @@ -222,6 +232,9 @@ if(FASTMCPP_BUILD_TESTS) add_test(NAME fastmcpp_cli_sum COMMAND fastmcpp client sum 2 3) add_test(NAME fastmcpp_cli_tasks_help COMMAND fastmcpp tasks --help) + add_test(NAME fastmcpp_cli_tasks_demo COMMAND fastmcpp tasks demo) + add_executable(fastmcpp_cli_tasks_ux tests/cli/tasks_cli.cpp) + add_test(NAME fastmcpp_cli_tasks_ux COMMAND fastmcpp_cli_tasks_ux) add_executable(fastmcpp_http_integration tests/server/http_integration.cpp) target_link_libraries(fastmcpp_http_integration PRIVATE fastmcpp_core) @@ -414,8 +427,12 @@ if(FASTMCPP_BUILD_TESTS) add_test(NAME fastmcpp_client_api_icons COMMAND fastmcpp_client_api_icons) 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) + target_link_libraries(fastmcpp_client_tasks PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_client_tasks COMMAND fastmcpp_client_tasks) + + add_executable(fastmcpp_client_sampling_handlers tests/client/sampling_handlers.cpp) + target_link_libraries(fastmcpp_client_sampling_handlers PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_client_sampling_handlers COMMAND fastmcpp_client_sampling_handlers) add_executable(fastmcpp_server_middleware tests/server/middleware.cpp) target_link_libraries(fastmcpp_server_middleware PRIVATE fastmcpp_core) @@ -438,6 +455,11 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_app_mounting PRIVATE fastmcpp_core) add_test(NAME fastmcpp_app_mounting COMMAND fastmcpp_app_mounting) + # App ergonomics tests + add_executable(fastmcpp_app_ergonomics tests/app/ergonomics.cpp) + target_link_libraries(fastmcpp_app_ergonomics PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_app_ergonomics COMMAND fastmcpp_app_ergonomics) + # Proxy tests add_executable(fastmcpp_proxy_basic tests/proxy/basic.cpp) target_link_libraries(fastmcpp_proxy_basic PRIVATE fastmcpp_core) diff --git a/include/fastmcpp/app.hpp b/include/fastmcpp/app.hpp index 3881984..0a2dbf5 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -57,6 +57,38 @@ struct ProxyMountedApp class FastMCP { public: + struct ToolOptions + { + std::optional title; + std::optional description; + std::optional> icons; + std::vector exclude_args; + TaskSupport task_support{TaskSupport::Forbidden}; + Json output_schema{Json::object()}; + }; + + struct PromptOptions + { + std::optional description; + std::optional meta; + std::vector arguments; + TaskSupport task_support{TaskSupport::Forbidden}; + }; + + struct ResourceOptions + { + std::optional description; + std::optional mime_type; + TaskSupport task_support{TaskSupport::Forbidden}; + }; + + struct ResourceTemplateOptions + { + std::optional description; + std::optional mime_type; + TaskSupport task_support{TaskSupport::Forbidden}; + }; + /// Construct app with metadata explicit FastMCP(std::string name = "fastmcpp_app", std::string version = "1.0.0", std::optional website_url = std::nullopt, @@ -117,6 +149,49 @@ class FastMCP return server_; } + // ========================================================================= + // Ergonomic registration helpers (Python FastMCP decorator-style analogs) + // ========================================================================= + + /// Register a tool using either a full JSON Schema or a "simple" param map + /// (e.g., {"a":"number","b":"integer"}). + FastMCP& tool(std::string name, const Json& input_schema_or_simple, tools::Tool::Fn fn, + ToolOptions options); + FastMCP& tool(std::string name, const Json& input_schema_or_simple, tools::Tool::Fn fn); + + /// Register a zero-argument tool (input schema defaults to {}). + FastMCP& tool(std::string name, tools::Tool::Fn fn, ToolOptions options); + FastMCP& tool(std::string name, tools::Tool::Fn fn); + + /// Register a prompt generator (equivalent to Python's @server.prompt). + FastMCP& prompt(std::string name, + std::function(const Json&)> generator, + PromptOptions options); + FastMCP& prompt(std::string name, + std::function(const Json&)> generator); + + /// Register a template-backed prompt (legacy Prompt template string). + FastMCP& prompt_template(std::string name, std::string template_string, PromptOptions options); + FastMCP& prompt_template(std::string name, std::string template_string); + + /// Register a concrete resource (equivalent to Python's @server.resource for fixed URIs). + FastMCP& resource(std::string uri, std::string name, + std::function provider, + ResourceOptions options); + FastMCP& resource(std::string uri, std::string name, + std::function provider); + + /// Register a resource template (equivalent to Python's @server.resource for templated URIs). + /// If parameters_schema_or_simple is empty, parameters are derived from the URI template. + FastMCP& + resource_template(std::string uri_template, std::string name, + std::function provider, + const Json& parameters_schema_or_simple, ResourceTemplateOptions options); + FastMCP& + resource_template(std::string uri_template, std::string name, + std::function provider, + const Json& parameters_schema_or_simple = Json::object()); + // ========================================================================= // App Mounting // ========================================================================= diff --git a/include/fastmcpp/client/client.hpp b/include/fastmcpp/client/client.hpp index e4e8646..57ca821 100644 --- a/include/fastmcpp/client/client.hpp +++ b/include/fastmcpp/client/client.hpp @@ -238,7 +238,7 @@ class Client /// @param options Call options (timeout, meta, progress handler) /// @return CallToolResult with content, error status, and metadata CallToolResult call_tool_mcp(const std::string& name, const fastmcpp::Json& arguments, - const CallToolOptions& options = {}) + const CallToolOptions& options = CallToolOptions{}) { fastmcpp::Json payload = {{"name", name}, {"arguments", arguments}}; diff --git a/include/fastmcpp/client/sampling_handlers.hpp b/include/fastmcpp/client/sampling_handlers.hpp new file mode 100644 index 0000000..e80ce4e --- /dev/null +++ b/include/fastmcpp/client/sampling_handlers.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "fastmcpp/types.hpp" + +#include +#include +#include + +namespace fastmcpp::client::sampling::handlers +{ + +struct OpenAICompatibleOptions +{ + std::string base_url = "https://api.openai.com"; + std::string endpoint_path = "/v1/chat/completions"; + + std::optional api_key; + std::string api_key_env = "OPENAI_API_KEY"; + + std::string default_model = "gpt-4o-mini"; + std::optional organization; + std::optional project; + + int timeout_ms = 60000; +}; + +/// Create a sampling/createMessage callback that calls an OpenAI-compatible +/// chat completions endpoint and returns MCP CreateMessageResult(+WithTools). +std::function +create_openai_compatible_sampling_callback(OpenAICompatibleOptions options); + +struct AnthropicOptions +{ + std::string base_url = "https://api.anthropic.com"; + std::string endpoint_path = "/v1/messages"; + + std::optional api_key; + std::string api_key_env = "ANTHROPIC_API_KEY"; + + std::string default_model = "claude-sonnet-4-5"; + std::string anthropic_version = "2023-06-01"; + + int timeout_ms = 60000; +}; + +/// Create a sampling/createMessage callback that calls the Anthropic Messages +/// API and returns MCP CreateMessageResult(+WithTools). +std::function +create_anthropic_sampling_callback(AnthropicOptions options); + +} // namespace fastmcpp::client::sampling::handlers diff --git a/include/fastmcpp/client/types.hpp b/include/fastmcpp/client/types.hpp index b765e6d..0d1fe1d 100644 --- a/include/fastmcpp/client/types.hpp +++ b/include/fastmcpp/client/types.hpp @@ -141,6 +141,7 @@ struct ResourceTemplate std::optional title; ///< Human-readable title std::optional description; std::optional mimeType; + std::optional parameters; ///< JSON Schema for template parameters std::optional annotations; std::optional> icons; ///< Icons for UI display std::optional _meta; ///< Protocol metadata @@ -398,6 +399,8 @@ inline void to_json(fastmcpp::Json& j, const ResourceTemplate& t) j["description"] = *t.description; if (t.mimeType) j["mimeType"] = *t.mimeType; + if (t.parameters) + j["parameters"] = *t.parameters; if (t.annotations) j["annotations"] = *t.annotations; if (t.icons) @@ -416,6 +419,8 @@ inline void from_json(const fastmcpp::Json& j, ResourceTemplate& t) t.description = j["description"].get(); if (j.contains("mimeType")) t.mimeType = j["mimeType"].get(); + if (j.contains("parameters")) + t.parameters = j["parameters"]; if (j.contains("annotations")) t.annotations = j["annotations"]; if (j.contains("icons")) diff --git a/include/fastmcpp/server/context.hpp b/include/fastmcpp/server/context.hpp index bb24a2d..0d1686d 100644 --- a/include/fastmcpp/server/context.hpp +++ b/include/fastmcpp/server/context.hpp @@ -331,14 +331,15 @@ class Context /// @param params Optional sampling parameters /// @return SamplingResult with text/image/audio content /// @throws std::runtime_error if sampling not available - SamplingResult sample(const std::string& message, const SamplingParams& params = {}) const + SamplingResult sample(const std::string& message, + const SamplingParams& params = SamplingParams{}) const { std::vector msgs = {{"user", message}}; return sample(msgs, params); } SamplingResult sample(const std::vector& messages, - const SamplingParams& params = {}) const + const SamplingParams& params = SamplingParams{}) const { if (!sampling_callback_) throw std::runtime_error("Sampling not available: no sampling callback set"); @@ -346,7 +347,8 @@ class Context } /// Convenience: sample and return just the text content - std::string sample_text(const std::string& message, const SamplingParams& params = {}) const + std::string sample_text(const std::string& message, + const SamplingParams& params = SamplingParams{}) const { auto result = sample(message, params); return result.content; diff --git a/src/app.cpp b/src/app.cpp index 585247b..d7fc024 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -4,8 +4,11 @@ #include "fastmcpp/client/types.hpp" #include "fastmcpp/exceptions.hpp" #include "fastmcpp/mcp/handler.hpp" +#include "fastmcpp/resources/template.hpp" +#include "fastmcpp/util/schema_build.hpp" #include +#include namespace fastmcpp { @@ -16,6 +19,125 @@ FastMCP::FastMCP(std::string name, std::string version, std::optional(const Json&)> generator, + PromptOptions options) +{ + prompts::Prompt p; + p.name = std::move(name); + p.description = std::move(options.description); + p.meta = std::move(options.meta); + p.arguments = std::move(options.arguments); + p.generator = std::move(generator); + p.task_support = options.task_support; + prompts_.register_prompt(p); + return *this; +} + +FastMCP& FastMCP::prompt_template(std::string name, std::string template_string, + PromptOptions options) +{ + prompts::Prompt p{std::move(template_string)}; + p.name = std::move(name); + p.description = std::move(options.description); + p.meta = std::move(options.meta); + p.arguments = std::move(options.arguments); + p.task_support = options.task_support; + prompts_.register_prompt(p); + return *this; +} + +FastMCP& FastMCP::resource(std::string uri, std::string name, + std::function provider, + ResourceOptions options) +{ + resources::Resource r; + r.uri = std::move(uri); + r.name = std::move(name); + r.description = std::move(options.description); + r.mime_type = std::move(options.mime_type); + r.provider = std::move(provider); + r.task_support = options.task_support; + resources_.register_resource(r); + return *this; +} + +FastMCP& +FastMCP::resource_template(std::string uri_template, std::string name, + std::function provider, + const Json& parameters_schema_or_simple, ResourceTemplateOptions options) +{ + resources::ResourceTemplate templ; + templ.uri_template = std::move(uri_template); + templ.name = std::move(name); + templ.description = std::move(options.description); + templ.mime_type = std::move(options.mime_type); + templ.provider = std::move(provider); + + if (parameters_schema_or_simple.is_object() && parameters_schema_or_simple.empty()) + templ.parameters = build_resource_template_parameters_schema(templ.uri_template); + else + templ.parameters = schema_from_schema_or_simple(parameters_schema_or_simple); + + resources_.register_template(std::move(templ)); + return *this; +} + void FastMCP::mount(FastMCP& app, const std::string& prefix, bool as_proxy) { mount(app, prefix, as_proxy, std::nullopt); @@ -777,4 +899,40 @@ prompts::PromptResult FastMCP::get_prompt_result(const std::string& name, const throw NotFoundError("prompt not found: " + name); } +FastMCP& FastMCP::tool(std::string name, const Json& input_schema_or_simple, tools::Tool::Fn fn) +{ + return tool(std::move(name), input_schema_or_simple, std::move(fn), ToolOptions{}); +} + +FastMCP& FastMCP::tool(std::string name, tools::Tool::Fn fn) +{ + return tool(std::move(name), std::move(fn), ToolOptions{}); +} + +FastMCP& FastMCP::prompt(std::string name, + std::function(const Json&)> generator) +{ + return prompt(std::move(name), std::move(generator), PromptOptions{}); +} + +FastMCP& FastMCP::prompt_template(std::string name, std::string template_string) +{ + return prompt_template(std::move(name), std::move(template_string), PromptOptions{}); +} + +FastMCP& FastMCP::resource(std::string uri, std::string name, + std::function provider) +{ + return resource(std::move(uri), std::move(name), std::move(provider), ResourceOptions{}); +} + +FastMCP& +FastMCP::resource_template(std::string uri_template, std::string name, + std::function provider, + const Json& parameters_schema_or_simple) +{ + return resource_template(std::move(uri_template), std::move(name), std::move(provider), + parameters_schema_or_simple, ResourceTemplateOptions{}); +} + } // namespace fastmcpp diff --git a/src/cli/main.cpp b/src/cli/main.cpp index e623eba..e71b569 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -1,10 +1,23 @@ +#include "fastmcpp/app.hpp" #include "fastmcpp/client/client.hpp" +#include "fastmcpp/client/transports.hpp" +#include "fastmcpp/mcp/handler.hpp" #include "fastmcpp/server/server.hpp" #include "fastmcpp/version.hpp" +#include #include #include +#include +#include +#include #include +#include +#include +#include + +namespace +{ static int usage(int exit_code = 1) { @@ -22,21 +35,387 @@ static int tasks_usage(int exit_code = 1) std::cout << "fastmcpp tasks\n"; std::cout << "Usage:\n"; std::cout << " fastmcpp tasks --help\n"; + std::cout << " fastmcpp tasks demo\n"; + std::cout << " fastmcpp tasks list [connection options] [--cursor ] [--limit ] " + "[--pretty]\n"; + std::cout << " fastmcpp tasks get [connection options] [--pretty]\n"; + std::cout << " fastmcpp tasks cancel [connection options] [--pretty]\n"; + std::cout << " fastmcpp tasks result [connection options] [--wait] [--timeout-ms " + "] [--pretty]\n"; + std::cout << "\n"; + std::cout << "Connection options:\n"; + std::cout + << " --http HTTP/SSE base URL (e.g. http://127.0.0.1:8000)\n"; + std::cout + << " --streamable-http Streamable HTTP base URL (default MCP path: /mcp)\n"; + std::cout << " --mcp-path Override MCP path for streamable HTTP\n"; + std::cout << " --ws WebSocket URL (e.g. ws://127.0.0.1:8765)\n"; + std::cout << " --stdio Spawn an MCP stdio server\n"; + std::cout << " --stdio-arg Repeatable args for --stdio\n"; std::cout << "\n"; std::cout << "Notes:\n"; - std::cout << " - fastmcpp provides in-process \"tasks\" building blocks (SEP-1686 subset),\n"; - std::cout << " but does not ship a distributed worker/queue like the Python fastmcp CLI.\n"; - std::cout << " - Use the C++ APIs (ToolTask / TaskStatus) and server notifications instead.\n"; + std::cout << " - Python fastmcp's `tasks` CLI is for Docket (distributed workers/Redis).\n"; + std::cout << " - fastmcpp provides MCP Tasks protocol client ops (SEP-1686 subset): " + "list/get/cancel/result.\n"; + std::cout << " - Use `fastmcpp tasks demo` for an in-process example (no network required).\n"; return exit_code; } +struct TasksConnection +{ + enum class Kind + { + Http, + StreamableHttp, + WebSocket, + Stdio, + }; + + Kind kind = Kind::Http; + std::string url_or_command; + std::string mcp_path = "/mcp"; + std::vector stdio_args; +}; + +static bool is_flag(const std::string& s) +{ + return !s.empty() && s[0] == '-'; +} + +static std::optional consume_flag_value(std::vector& args, + const std::string& flag) +{ + for (size_t i = 0; i + 1 < args.size(); ++i) + { + if (args[i] == flag) + { + std::string value = args[i + 1]; + args.erase(args.begin() + static_cast(i), + args.begin() + static_cast(i) + 2); + return value; + } + } + return std::nullopt; +} + +static bool consume_flag(std::vector& args, const std::string& flag) +{ + for (size_t i = 0; i < args.size(); ++i) + { + if (args[i] == flag) + { + args.erase(args.begin() + static_cast(i)); + return true; + } + } + return false; +} + +static int parse_int(const std::string& s, int default_value) +{ + try + { + size_t pos = 0; + int v = std::stoi(s, &pos, 10); + if (pos != s.size()) + return default_value; + return v; + } + catch (...) + { + return default_value; + } +} + +static std::optional parse_tasks_connection(std::vector& args) +{ + TasksConnection conn; + bool saw_any = false; + + if (auto http = consume_flag_value(args, "--http")) + { + conn.kind = TasksConnection::Kind::Http; + conn.url_or_command = *http; + saw_any = true; + } + if (auto streamable = consume_flag_value(args, "--streamable-http")) + { + conn.kind = TasksConnection::Kind::StreamableHttp; + conn.url_or_command = *streamable; + saw_any = true; + } + if (auto mcp_path = consume_flag_value(args, "--mcp-path")) + conn.mcp_path = *mcp_path; + if (auto ws = consume_flag_value(args, "--ws")) + { + conn.kind = TasksConnection::Kind::WebSocket; + conn.url_or_command = *ws; + saw_any = true; + } + if (auto stdio = consume_flag_value(args, "--stdio")) + { + conn.kind = TasksConnection::Kind::Stdio; + conn.url_or_command = *stdio; + saw_any = true; + } + + while (true) + { + auto arg = consume_flag_value(args, "--stdio-arg"); + if (!arg) + break; + conn.stdio_args.push_back(*arg); + } + + if (!saw_any) + return std::nullopt; + return conn; +} + +static fastmcpp::client::Client make_client_from_connection(const TasksConnection& conn) +{ + using namespace fastmcpp::client; + switch (conn.kind) + { + case TasksConnection::Kind::Http: + return Client(std::make_unique(conn.url_or_command)); + case TasksConnection::Kind::StreamableHttp: + return Client( + std::make_unique(conn.url_or_command, conn.mcp_path)); + case TasksConnection::Kind::WebSocket: + return Client(std::make_unique(conn.url_or_command)); + case TasksConnection::Kind::Stdio: + return Client(std::make_unique(conn.url_or_command, conn.stdio_args)); + } + throw std::runtime_error("Unsupported transport kind"); +} + +static int run_tasks_demo() +{ + using namespace fastmcpp; + + FastMCP app("fastmcpp-cli-tasks-demo", "1.0.0"); + Json input_schema = {{"type", "object"}, + {"properties", Json::object({{"ms", Json{{"type", "number"}}}})}}; + + tools::Tool sleep_tool{"sleep_ms", input_schema, Json{{"type", "number"}}, [](const Json& in) + { + int ms = 50; + if (in.contains("ms") && in["ms"].is_number()) + ms = static_cast(in["ms"].get()); + if (ms < 0) + ms = 0; + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); + return Json(ms); + }}; + sleep_tool.set_task_support(TaskSupport::Optional); + app.tools().register_tool(sleep_tool); + + auto handler = mcp::make_mcp_handler(app); + fastmcpp::client::Client c( + std::make_unique(std::move(handler))); + + fastmcpp::Json payload = {{"name", "sleep_ms"}, {"arguments", Json{{"ms", 50}}}}; + payload["_meta"] = Json{{"modelcontextprotocol.io/task", Json{{"ttl", 60000}}}}; + fastmcpp::Json call_res = c.call("tools/call", payload); + std::cout << call_res.dump(2) << "\n"; + + if (call_res.contains("_meta") && call_res["_meta"].contains("modelcontextprotocol.io/task")) + { + const auto& t = call_res["_meta"]["modelcontextprotocol.io/task"]; + if (t.contains("taskId")) + { + std::string task_id = t["taskId"].get(); + fastmcpp::Json status = c.call("tasks/get", Json{{"taskId", task_id}}); + std::cout << status.dump(2) << "\n"; + + auto start = std::chrono::steady_clock::now(); + while (true) + { + status = c.call("tasks/get", Json{{"taskId", task_id}}); + std::string s = status.value("status", ""); + if (s == "completed" || s == "failed" || s == "cancelled") + break; + if (std::chrono::steady_clock::now() - start > std::chrono::seconds(2)) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + fastmcpp::Json result = c.call("tasks/result", Json{{"taskId", task_id}}); + std::cout << result.dump(2) << "\n"; + } + } + + return 0; +} + +static int run_tasks_command(int argc, char** argv) +{ + if (argc < 3) + return tasks_usage(1); + + std::vector args; + args.reserve(static_cast(argc)); + for (int i = 2; i < argc; ++i) + args.emplace_back(argv[i]); + + if (consume_flag(args, "--help") || consume_flag(args, "-h")) + return tasks_usage(0); + + if (args.empty()) + return tasks_usage(1); + + std::string sub = args.front(); + args.erase(args.begin()); + + if (sub == "demo") + return run_tasks_demo(); + + bool pretty = consume_flag(args, "--pretty"); + bool wait = consume_flag(args, "--wait"); + int timeout_ms = 60000; + if (auto t = consume_flag_value(args, "--timeout-ms")) + timeout_ms = parse_int(*t, timeout_ms); + + std::vector remaining = args; + auto conn = parse_tasks_connection(remaining); + if (!conn) + { + std::cerr << "Missing connection options. See: fastmcpp tasks --help\n"; + return 2; + } + + auto dump_json = [pretty](const fastmcpp::Json& j) + { std::cout << (pretty ? j.dump(2) : j.dump()) << "\n"; }; + + auto reject_unknown_flags = [](const std::vector& rest) + { + for (const auto& a : rest) + if (is_flag(a)) + return a; + return std::string(); + }; + + try + { + if (sub == "list") + { + std::optional cursor; + if (auto c = consume_flag_value(remaining, "--cursor")) + cursor = *c; + int limit = 50; + if (auto l = consume_flag_value(remaining, "--limit")) + limit = parse_int(*l, limit); + + if (auto bad = reject_unknown_flags(remaining); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + + auto client = make_client_from_connection(*conn); + fastmcpp::Json res = client.list_tasks_raw(cursor, limit); + dump_json(res); + return 0; + } + + if (sub == "get" || sub == "cancel" || sub == "result") + { + std::string task_id; + if (!remaining.empty() && !is_flag(remaining.front())) + { + task_id = remaining.front(); + remaining.erase(remaining.begin()); + } + + if (task_id.empty()) + { + std::cerr << "Missing taskId\n"; + return 2; + } + + if (auto bad = reject_unknown_flags(remaining); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + + if (sub == "get") + { + auto client = make_client_from_connection(*conn); + fastmcpp::Json res = client.call("tasks/get", fastmcpp::Json{{"taskId", task_id}}); + dump_json(res); + return 0; + } + + if (sub == "cancel") + { + auto client = make_client_from_connection(*conn); + fastmcpp::Json res = + client.call("tasks/cancel", fastmcpp::Json{{"taskId", task_id}}); + dump_json(res); + return 0; + } + + if (sub == "result") + { + auto client = make_client_from_connection(*conn); + if (wait) + { + auto start = std::chrono::steady_clock::now(); + while (true) + { + fastmcpp::Json status = + client.call("tasks/get", fastmcpp::Json{{"taskId", task_id}}); + std::string s = status.value("status", ""); + if (s == "completed") + break; + if (s == "failed" || s == "cancelled") + { + dump_json(status); + return 3; + } + if (timeout_ms > 0 && std::chrono::steady_clock::now() - start >= + std::chrono::milliseconds(timeout_ms)) + { + dump_json(status); + return 4; + } + int poll_ms = status.value("pollInterval", 1000); + if (poll_ms <= 0) + poll_ms = 1000; + std::this_thread::sleep_for(std::chrono::milliseconds(poll_ms)); + } + } + + fastmcpp::Json res = + client.call("tasks/result", fastmcpp::Json{{"taskId", task_id}}); + dump_json(res); + return 0; + } + } + + std::cerr << "Unknown tasks subcommand: " << sub << "\n"; + return 2; + } + catch (const std::exception& e) + { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} + +} // namespace + int main(int argc, char** argv) { if (argc < 2) return usage(); + std::string cmd = argv[1]; if (cmd == "--help" || cmd == "-h") return usage(0); + if (cmd == "client") { if (argc >= 5 && std::string(argv[2]) == "sum") @@ -53,11 +432,9 @@ int main(int argc, char** argv) } return usage(); } + if (cmd == "tasks") - { - if (argc >= 3 && (std::string(argv[2]) == "--help" || std::string(argv[2]) == "-h")) - return tasks_usage(0); - return tasks_usage(0); - } + return run_tasks_command(argc, argv); + return usage(); } diff --git a/src/client/sampling_handlers.cpp b/src/client/sampling_handlers.cpp new file mode 100644 index 0000000..28d4f78 --- /dev/null +++ b/src/client/sampling_handlers.cpp @@ -0,0 +1,622 @@ +#include "fastmcpp/client/sampling_handlers.hpp" + +#include "fastmcpp/exceptions.hpp" + +#include + +#ifdef FASTMCPP_HAS_CURL +#include +#endif + +#include +#include +#include +#include +#include +#include +#include + +namespace fastmcpp::client::sampling::handlers +{ +namespace +{ + +#ifdef FASTMCPP_HAS_CURL +struct CurlResponse +{ + long status_code = 0; + std::string body; +}; +#endif + +static std::string trim_trailing_slash(std::string s) +{ + while (!s.empty() && s.back() == '/') + s.pop_back(); + return s; +} + +static std::string join_url(const std::string& base_url, const std::string& path) +{ + std::string base = trim_trailing_slash(base_url); + if (path.empty()) + return base; + if (!path.empty() && path.front() == '/') + return base + path; + return base + "/" + path; +} + +static std::optional get_env(const std::string& name) +{ + if (name.empty()) + return std::nullopt; + if (const char* v = std::getenv(name.c_str()); v != nullptr && v[0] != '\0') + return std::string(v); + return std::nullopt; +} + +#ifdef FASTMCPP_HAS_CURL +static size_t write_to_string(void* ptr, size_t size, size_t nmemb, void* userdata) +{ + size_t total = size * nmemb; + auto* out = static_cast(userdata); + out->append(static_cast(ptr), total); + return total; +} + +static void ensure_curl_initialized() +{ + static bool initialized = false; + static std::mutex init_mutex; + std::lock_guard lock(init_mutex); + if (!initialized) + { + curl_global_init(CURL_GLOBAL_DEFAULT); + initialized = true; + } +} + +static CurlResponse curl_post_json(const std::string& url, const std::vector& headers, + const std::string& body, int timeout_ms) +{ + ensure_curl_initialized(); + + CURL* curl = curl_easy_init(); + if (!curl) + throw std::runtime_error("curl_easy_init failed"); + + struct curl_slist* hdrs = nullptr; + for (const auto& h : headers) + hdrs = curl_slist_append(hdrs, h.c_str()); + + CurlResponse response; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(body.size())); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &write_to_string); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms > 0 ? timeout_ms : 0); + + CURLcode rc = curl_easy_perform(curl); + if (rc != CURLE_OK) + { + std::string err = curl_easy_strerror(rc); + curl_slist_free_all(hdrs); + curl_easy_cleanup(curl); + throw std::runtime_error("curl_easy_perform failed: " + err); + } + + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response.status_code); + + curl_slist_free_all(hdrs); + curl_easy_cleanup(curl); + return response; +} +#endif + +static std::vector normalize_content_to_array(const fastmcpp::Json& content) +{ + if (content.is_array()) + return content.get>(); + if (content.is_object()) + return {content}; + return {}; +} + +static std::string join_text_blocks(const fastmcpp::Json& content) +{ + std::string out; + for (const auto& block : normalize_content_to_array(content)) + { + if (!block.is_object()) + continue; + if (block.value("type", "") != "text") + continue; + if (!block.contains("text") || !block["text"].is_string()) + continue; + if (!out.empty()) + out.append("\n"); + out.append(block["text"].get()); + } + return out; +} + +static std::vector extract_blocks_by_type(const fastmcpp::Json& content, + const std::string& type) +{ + std::vector blocks; + for (const auto& block : normalize_content_to_array(content)) + { + if (!block.is_object()) + continue; + if (block.value("type", "") != type) + continue; + blocks.push_back(block); + } + return blocks; +} + +static std::string select_model_from_preferences(const fastmcpp::Json& params, + const std::string& default_model) +{ + if (params.contains("modelPreferences")) + { + const auto& mp = params["modelPreferences"]; + if (mp.is_string()) + return mp.get(); + if (mp.is_object() && mp.contains("hints") && mp["hints"].is_array() && + !mp["hints"].empty() && mp["hints"][0].is_string()) + return mp["hints"][0].get(); + } + return default_model; +} + +static fastmcpp::Json convert_mcp_tools_to_openai(const fastmcpp::Json& tools) +{ + fastmcpp::Json out = fastmcpp::Json::array(); + if (!tools.is_array()) + return out; + + for (const auto& t : tools) + { + if (!t.is_object()) + continue; + std::string name = t.value("name", ""); + if (name.empty()) + continue; + + fastmcpp::Json parameters = t.contains("inputSchema") && t["inputSchema"].is_object() + ? t["inputSchema"] + : fastmcpp::Json::object(); + if (!parameters.contains("type")) + parameters["type"] = "object"; + + fastmcpp::Json fn = {{"name", name}, {"parameters", std::move(parameters)}}; + if (t.contains("description") && t["description"].is_string()) + fn["description"] = t["description"].get(); + + out.push_back(fastmcpp::Json{{"type", "function"}, {"function", std::move(fn)}}); + } + return out; +} + +static fastmcpp::Json convert_mcp_tool_choice_to_openai(const fastmcpp::Json& tool_choice) +{ + if (!tool_choice.is_object()) + return fastmcpp::Json(); + std::string mode = tool_choice.value("mode", ""); + if (mode == "auto" || mode == "required" || mode == "none") + return mode; + return fastmcpp::Json(); +} + +static fastmcpp::Json build_openai_messages(const fastmcpp::Json& params) +{ + fastmcpp::Json messages = fastmcpp::Json::array(); + + if (params.contains("systemPrompt") && params["systemPrompt"].is_string()) + messages.push_back(fastmcpp::Json{{"role", "system"}, {"content", params["systemPrompt"]}}); + + if (!params.contains("messages") || !params["messages"].is_array()) + return messages; + + for (const auto& msg : params["messages"]) + { + if (!msg.is_object()) + continue; + + std::string role = msg.value("role", ""); + if (!msg.contains("content")) + continue; + const auto& content = msg["content"]; + + // Tool results are represented as "tool" messages in OpenAI. + for (const auto& tr : extract_blocks_by_type(content, "tool_result")) + { + std::string tool_use_id = tr.value("toolUseId", ""); + std::string text = tr.contains("content") ? join_text_blocks(tr["content"]) : ""; + if (tool_use_id.empty()) + continue; + messages.push_back( + fastmcpp::Json{{"role", "tool"}, {"tool_call_id", tool_use_id}, {"content", text}}); + } + + std::string text = join_text_blocks(content); + auto tool_uses = extract_blocks_by_type(content, "tool_use"); + + if (role == "assistant" && !tool_uses.empty()) + { + fastmcpp::Json tool_calls = fastmcpp::Json::array(); + for (const auto& tu : tool_uses) + { + std::string id = tu.value("id", ""); + std::string name = tu.value("name", ""); + fastmcpp::Json input = + tu.contains("input") ? tu["input"] : fastmcpp::Json::object(); + if (id.empty() || name.empty()) + continue; + + tool_calls.push_back(fastmcpp::Json{ + {"id", id}, + {"type", "function"}, + {"function", fastmcpp::Json{{"name", name}, {"arguments", input.dump()}}}, + }); + } + + fastmcpp::Json assistant = + fastmcpp::Json{{"role", "assistant"}, {"tool_calls", tool_calls}}; + if (!text.empty()) + assistant["content"] = text; + messages.push_back(std::move(assistant)); + continue; + } + + if (!text.empty() && (role == "user" || role == "assistant")) + messages.push_back(fastmcpp::Json{{"role", role}, {"content", text}}); + } + + return messages; +} + +static fastmcpp::Json openai_response_to_mcp_result(const fastmcpp::Json& response, + const std::string& requested_model) +{ + if (!response.is_object() || !response.contains("choices") || !response["choices"].is_array() || + response["choices"].empty()) + throw std::runtime_error("OpenAI response missing choices"); + + const auto& choice = response["choices"][0]; + if (!choice.is_object() || !choice.contains("message") || !choice["message"].is_object()) + throw std::runtime_error("OpenAI response missing message"); + + const auto& msg = choice["message"]; + std::string content_text = msg.value("content", ""); + fastmcpp::Json tool_calls = + msg.contains("tool_calls") ? msg["tool_calls"] : fastmcpp::Json::array(); + + std::string finish = choice.value("finish_reason", ""); + + std::string stop_reason = "endTurn"; + if (finish == "tool_calls") + stop_reason = "toolUse"; + else if (finish == "length") + stop_reason = "maxTokens"; + + fastmcpp::Json content = fastmcpp::Json::array(); + if (!content_text.empty()) + content.push_back(fastmcpp::Json{{"type", "text"}, {"text", content_text}}); + + if (tool_calls.is_array()) + { + for (const auto& tc : tool_calls) + { + if (!tc.is_object()) + continue; + std::string id = tc.value("id", ""); + if (!tc.contains("function") || !tc["function"].is_object()) + continue; + std::string name = tc["function"].value("name", ""); + std::string args_str = tc["function"].value("arguments", ""); + if (id.empty() || name.empty()) + continue; + + fastmcpp::Json input = fastmcpp::Json::object(); + if (!args_str.empty()) + { + try + { + input = fastmcpp::Json::parse(args_str); + } + catch (...) + { + input = fastmcpp::Json::object(); + } + } + + content.push_back( + fastmcpp::Json{{"type", "tool_use"}, {"id", id}, {"name", name}, {"input", input}}); + } + } + + if (!content.empty() && !tool_calls.empty()) + stop_reason = "toolUse"; + + std::string model = response.value("model", requested_model); + + return fastmcpp::Json{ + {"role", "assistant"}, + {"model", model}, + {"stopReason", stop_reason}, + {"content", content}, + }; +} + +static fastmcpp::Json build_anthropic_messages(const fastmcpp::Json& params) +{ + fastmcpp::Json messages = fastmcpp::Json::array(); + if (!params.contains("messages") || !params["messages"].is_array()) + return messages; + + for (const auto& msg : params["messages"]) + { + if (!msg.is_object()) + continue; + std::string role = msg.value("role", ""); + if (role != "user" && role != "assistant") + continue; + + if (!msg.contains("content")) + continue; + const auto& content = msg["content"]; + + fastmcpp::Json blocks = fastmcpp::Json::array(); + for (const auto& block : normalize_content_to_array(content)) + { + if (!block.is_object()) + continue; + std::string type = block.value("type", ""); + if (type == "text") + { + blocks.push_back(block); + continue; + } + if (type == "tool_use") + { + fastmcpp::Json out = block; + blocks.push_back(std::move(out)); + continue; + } + if (type == "tool_result") + { + // Anthropic expects tool_use_id and string content. + std::string tool_use_id = block.value("toolUseId", ""); + std::string text = + block.contains("content") ? join_text_blocks(block["content"]) : ""; + if (tool_use_id.empty()) + continue; + blocks.push_back(fastmcpp::Json{{"type", "tool_result"}, + {"tool_use_id", tool_use_id}, + {"content", text}, + {"is_error", block.value("isError", false)}}); + continue; + } + } + + messages.push_back(fastmcpp::Json{{"role", role}, {"content", blocks}}); + } + + return messages; +} + +static fastmcpp::Json convert_mcp_tools_to_anthropic(const fastmcpp::Json& tools) +{ + fastmcpp::Json out = fastmcpp::Json::array(); + if (!tools.is_array()) + return out; + for (const auto& t : tools) + { + if (!t.is_object()) + continue; + std::string name = t.value("name", ""); + if (name.empty()) + continue; + fastmcpp::Json input_schema = t.contains("inputSchema") && t["inputSchema"].is_object() + ? t["inputSchema"] + : fastmcpp::Json::object(); + if (!input_schema.contains("type")) + input_schema["type"] = "object"; + + fastmcpp::Json tool = {{"name", name}, {"input_schema", std::move(input_schema)}}; + if (t.contains("description") && t["description"].is_string()) + tool["description"] = t["description"].get(); + out.push_back(std::move(tool)); + } + return out; +} + +static fastmcpp::Json convert_mcp_tool_choice_to_anthropic(const fastmcpp::Json& tool_choice) +{ + if (!tool_choice.is_object()) + return fastmcpp::Json(); + std::string mode = tool_choice.value("mode", ""); + if (mode == "auto") + return fastmcpp::Json{{"type", "auto"}}; + if (mode == "required") + return fastmcpp::Json{{"type", "any"}}; + if (mode == "none") + return fastmcpp::Json(); + return fastmcpp::Json(); +} + +static fastmcpp::Json anthropic_response_to_mcp_result(const fastmcpp::Json& response, + const std::string& requested_model) +{ + if (!response.is_object()) + throw std::runtime_error("Anthropic response not an object"); + if (!response.contains("content") || !response["content"].is_array() || + response["content"].empty()) + throw std::runtime_error("Anthropic response missing content"); + + std::string stop = response.value("stop_reason", ""); + std::string stop_reason = "endTurn"; + if (stop == "tool_use") + stop_reason = "toolUse"; + else if (stop == "max_tokens") + stop_reason = "maxTokens"; + + fastmcpp::Json content = fastmcpp::Json::array(); + for (const auto& block : response["content"]) + { + if (!block.is_object()) + continue; + std::string type = block.value("type", ""); + if (type == "text") + { + content.push_back(fastmcpp::Json{{"type", "text"}, {"text", block.value("text", "")}}); + } + else if (type == "tool_use") + { + fastmcpp::Json input = + block.contains("input") ? block["input"] : fastmcpp::Json::object(); + content.push_back(fastmcpp::Json{{"type", "tool_use"}, + {"id", block.value("id", "")}, + {"name", block.value("name", "")}, + {"input", input}}); + } + } + + std::string model = response.value("model", requested_model); + return fastmcpp::Json{ + {"role", "assistant"}, {"model", model}, {"stopReason", stop_reason}, {"content", content}}; +} + +} // namespace + +std::function +create_openai_compatible_sampling_callback(OpenAICompatibleOptions options) +{ +#ifndef FASTMCPP_HAS_CURL + (void)options; + return [](const fastmcpp::Json&) -> fastmcpp::Json + { + throw std::runtime_error( + "fastmcpp built without libcurl; OpenAI sampling handler unavailable"); + }; +#else + return [options = std::move(options)](const fastmcpp::Json& params) -> fastmcpp::Json + { + OpenAICompatibleOptions opts = options; + if (!opts.api_key) + opts.api_key = get_env(opts.api_key_env); + + const std::string model = select_model_from_preferences(params, opts.default_model); + const std::string url = join_url(opts.base_url, opts.endpoint_path); + + fastmcpp::Json request = fastmcpp::Json::object(); + request["model"] = model; + request["messages"] = build_openai_messages(params); + + if (params.contains("temperature") && params["temperature"].is_number()) + request["temperature"] = params["temperature"]; + if (params.contains("maxTokens") && params["maxTokens"].is_number_integer()) + request["max_tokens"] = params["maxTokens"]; + if (params.contains("stopSequences") && params["stopSequences"].is_array()) + request["stop"] = params["stopSequences"]; + + if (params.contains("tools") && params["tools"].is_array()) + { + request["tools"] = convert_mcp_tools_to_openai(params["tools"]); + if (params.contains("toolChoice")) + { + fastmcpp::Json tc = convert_mcp_tool_choice_to_openai(params["toolChoice"]); + if (!tc.is_null()) + request["tool_choice"] = tc; + } + } + + std::vector headers; + headers.push_back("Content-Type: application/json"); + if (opts.api_key && !opts.api_key->empty()) + headers.push_back("Authorization: Bearer " + *opts.api_key); + if (opts.organization) + headers.push_back("OpenAI-Organization: " + *opts.organization); + if (opts.project) + headers.push_back("OpenAI-Project: " + *opts.project); + + CurlResponse r = curl_post_json(url, headers, request.dump(), opts.timeout_ms); + if (r.status_code >= 400) + throw std::runtime_error("OpenAI request failed HTTP " + std::to_string(r.status_code) + + ": " + r.body); + + fastmcpp::Json response = fastmcpp::Json::parse(r.body); + return openai_response_to_mcp_result(response, model); + }; +#endif +} + +std::function +create_anthropic_sampling_callback(AnthropicOptions options) +{ +#ifndef FASTMCPP_HAS_CURL + (void)options; + return [](const fastmcpp::Json&) -> fastmcpp::Json + { + throw std::runtime_error( + "fastmcpp built without libcurl; Anthropic sampling handler unavailable"); + }; +#else + return [options = std::move(options)](const fastmcpp::Json& params) -> fastmcpp::Json + { + AnthropicOptions opts = options; + if (!opts.api_key) + opts.api_key = get_env(opts.api_key_env); + + const std::string model = select_model_from_preferences(params, opts.default_model); + const std::string url = join_url(opts.base_url, opts.endpoint_path); + + fastmcpp::Json request = fastmcpp::Json::object(); + request["model"] = model; + request["max_tokens"] = + params.contains("maxTokens") && params["maxTokens"].is_number_integer() + ? params["maxTokens"] + : fastmcpp::Json(512); + request["messages"] = build_anthropic_messages(params); + + if (params.contains("systemPrompt") && params["systemPrompt"].is_string()) + request["system"] = params["systemPrompt"]; + if (params.contains("temperature") && params["temperature"].is_number()) + request["temperature"] = params["temperature"]; + if (params.contains("stopSequences") && params["stopSequences"].is_array()) + request["stop_sequences"] = params["stopSequences"]; + + if (params.contains("tools") && params["tools"].is_array()) + { + request["tools"] = convert_mcp_tools_to_anthropic(params["tools"]); + if (params.contains("toolChoice")) + { + fastmcpp::Json tc = convert_mcp_tool_choice_to_anthropic(params["toolChoice"]); + if (!tc.is_null() && !tc.empty()) + request["tool_choice"] = tc; + } + } + + std::vector headers; + headers.push_back("Content-Type: application/json"); + headers.push_back("anthropic-version: " + opts.anthropic_version); + if (opts.api_key && !opts.api_key->empty()) + headers.push_back("x-api-key: " + *opts.api_key); + + CurlResponse r = curl_post_json(url, headers, request.dump(), opts.timeout_ms); + if (r.status_code >= 400) + throw std::runtime_error("Anthropic request failed HTTP " + + std::to_string(r.status_code) + ": " + r.body); + + fastmcpp::Json response = fastmcpp::Json::parse(r.body); + return anthropic_response_to_mcp_result(response, model); + }; +#endif +} + +} // namespace fastmcpp::client::sampling::handlers diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index 7aa2f2e..53da602 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -12,11 +12,14 @@ #include #include #include +#include #include #include #include +#include #include #include +#include #include namespace fastmcpp::mcp @@ -1386,6 +1389,8 @@ make_mcp_handler(const std::string& server_name, const std::string& version, templ_json["description"] = *templ.description; if (templ.mime_type) templ_json["mimeType"] = *templ.mime_type; + templ_json["parameters"] = + templ.parameters.is_null() ? fastmcpp::Json::object() : templ.parameters; templates_array.push_back(templ_json); } return fastmcpp::Json{ @@ -1855,6 +1860,8 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) templ_json["description"] = *templ.description; if (templ.mime_type) templ_json["mimeType"] = *templ.mime_type; + templ_json["parameters"] = + templ.parameters.is_null() ? fastmcpp::Json::object() : templ.parameters; templates_array.push_back(templ_json); } return fastmcpp::Json{ @@ -2307,6 +2314,10 @@ std::function make_mcp_handler(const Prox templ_json["description"] = *templ.description; if (templ.mimeType) templ_json["mimeType"] = *templ.mimeType; + if (templ.parameters) + templ_json["parameters"] = *templ.parameters; + else + templ_json["parameters"] = fastmcpp::Json::object(); templates_array.push_back(templ_json); } return fastmcpp::Json{ @@ -2691,6 +2702,8 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces templ_json["description"] = *templ.description; if (templ.mime_type) templ_json["mimeType"] = *templ.mime_type; + templ_json["parameters"] = + templ.parameters.is_null() ? fastmcpp::Json::object() : templ.parameters; templates_array.push_back(templ_json); } return fastmcpp::Json{ diff --git a/tests/app/ergonomics.cpp b/tests/app/ergonomics.cpp new file mode 100644 index 0000000..cab62ca --- /dev/null +++ b/tests/app/ergonomics.cpp @@ -0,0 +1,203 @@ +// Unit tests for FastMCP ergonomic registration helpers +#include "fastmcpp/app.hpp" +#include "fastmcpp/mcp/handler.hpp" + +#include + +using namespace fastmcpp; + +// Avoid abort() / debug assertion dialogs on Windows by returning a non-zero exit code. +#define CHECK_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")" << std::endl; \ + return 1; \ + } \ + } while (0) + +static Json make_request(int id, std::string method, Json params = Json::object()) +{ + return Json{{"jsonrpc", "2.0"}, + {"id", id}, + {"method", std::move(method)}, + {"params", std::move(params)}}; +} + +static Json call(const std::function& handler, int id, std::string method, + Json params = Json::object()) +{ + return handler(make_request(id, std::move(method), std::move(params))); +} + +static int assert_has_tool(const Json& tools_list_result, const std::string& name) +{ + CHECK_TRUE(tools_list_result.contains("result"), "tools/list missing result"); + CHECK_TRUE(tools_list_result["result"].contains("tools"), "tools/list missing tools"); + bool found = false; + for (const auto& t : tools_list_result["result"]["tools"]) + { + if (t.value("name", "") == name) + { + found = true; + break; + } + } + CHECK_TRUE(found, "tool not found in tools/list: " + name); + return 0; +} + +static int test_tool_simple_schema() +{ + std::cout << "test_tool_simple_schema..." << std::endl; + + try + { + FastMCP app("ErgonomicsApp", "1.0.0"); + FastMCP::ToolOptions opts; + opts.description = "Add two numbers"; + opts.output_schema = Json{{"type", "number"}}; + + app.tool( + "add", Json{{"a", "number"}, {"b", "number"}}, [](const Json& in) + { return in.at("a").get() + in.at("b").get(); }, opts); + + auto handler = mcp::make_mcp_handler(app); + + auto list_resp = call(handler, 1, "tools/list"); + CHECK_TRUE(list_resp.contains("result"), "tools/list missing result"); + if (int rc = assert_has_tool(list_resp, "add"); rc != 0) + return rc; + + auto call_resp = call(handler, 2, "tools/call", + Json{{"name", "add"}, {"arguments", Json{{"a", 2}, {"b", 3}}}}); + CHECK_TRUE(call_resp.contains("result"), "tools/call missing result"); + CHECK_TRUE(call_resp["result"].contains("content"), "tools/call missing content"); + CHECK_TRUE(call_resp["result"]["content"].is_array(), "tools/call content not array"); + CHECK_TRUE(!call_resp["result"]["content"].empty(), "tools/call content empty"); + CHECK_TRUE(call_resp["result"]["content"][0].value("type", "") == "text", + "tools/call first block not text"); + CHECK_TRUE(call_resp["result"]["content"][0].value("text", "").find("5") != + std::string::npos, + "tools/call output missing expected value"); + + std::cout << " PASSED" << std::endl; + return 0; + } + catch (const std::exception& e) + { + std::cerr << "FAIL: unexpected exception: " << e.what() << std::endl; + return 1; + } +} + +static int test_prompt_and_resources() +{ + std::cout << "test_prompt_and_resources..." << std::endl; + + try + { + FastMCP app("ErgonomicsApp", "1.0.0"); + + FastMCP::PromptOptions prompt_opts; + prompt_opts.description = "A greeting prompt"; + prompt_opts.arguments = {{"name", std::optional{"Your name"}, true}}; + app.prompt( + "greet", + [](const Json& args) + { + const auto who = args.value("name", "world"); + return std::vector{{"user", "Hello " + who + "!"}}; + }, + prompt_opts); + + FastMCP::ResourceOptions res_opts; + res_opts.description = "A test resource"; + res_opts.mime_type = "text/plain"; + app.resource( + "file://hello.txt", "hello", + [](const Json&) + { + return resources::ResourceContent{"file://hello.txt", "text/plain", + std::string{"hello"}}; + }, + res_opts); + + app.resource_template("weather://{city}/current", "Weather", + [](const Json& params) + { + const auto city = params.value("city", "unknown"); + return resources::ResourceContent{ + "weather://" + city + "/current", "text/plain", + std::string{"sunny"}}; + }); + + auto handler = mcp::make_mcp_handler(app); + + auto prompts_list = call(handler, 10, "prompts/list"); + CHECK_TRUE(prompts_list.contains("result"), "prompts/list missing result"); + CHECK_TRUE(prompts_list["result"].contains("prompts"), "prompts/list missing prompts"); + bool prompt_found = false; + for (const auto& p : prompts_list["result"]["prompts"]) + if (p.value("name", "") == "greet") + prompt_found = true; + CHECK_TRUE(prompt_found, "prompt not found in prompts/list"); + + auto prompt_get = call(handler, 11, "prompts/get", + Json{{"name", "greet"}, {"arguments", Json{{"name", "Ada"}}}}); + CHECK_TRUE(prompt_get.contains("result"), "prompts/get missing result"); + CHECK_TRUE(prompt_get["result"].contains("messages"), "prompts/get missing messages"); + CHECK_TRUE(!prompt_get["result"]["messages"].empty(), "prompts/get messages empty"); + + auto resources_list = call(handler, 20, "resources/list"); + CHECK_TRUE(resources_list.contains("result"), "resources/list missing result"); + CHECK_TRUE(resources_list["result"].contains("resources"), + "resources/list missing resources"); + + auto read_resp = call(handler, 21, "resources/read", Json{{"uri", "file://hello.txt"}}); + CHECK_TRUE(read_resp.contains("result"), "resources/read missing result"); + CHECK_TRUE(read_resp["result"].contains("contents"), "resources/read missing contents"); + + auto templates_list = call(handler, 22, "resources/templates/list"); + CHECK_TRUE(templates_list.contains("result"), "resources/templates/list missing result"); + CHECK_TRUE(templates_list["result"].contains("resourceTemplates"), + "resources/templates/list missing resourceTemplates"); + + bool templ_found = false; + for (const auto& t : templates_list["result"]["resourceTemplates"]) + { + if (t.value("uriTemplate", "") == "weather://{city}/current") + { + templ_found = true; + CHECK_TRUE(t.contains("parameters"), "resource template missing parameters"); + CHECK_TRUE(t["parameters"].contains("properties"), + "resource template parameters missing properties"); + CHECK_TRUE(t["parameters"]["properties"].contains("city"), + "resource template parameters missing city"); + break; + } + } + CHECK_TRUE(templ_found, "resource template not found in templates/list"); + + std::cout << " PASSED" << std::endl; + return 0; + } + catch (const std::exception& e) + { + std::cerr << "FAIL: unexpected exception: " << e.what() << std::endl; + return 1; + } +} + +int main() +{ + int rc = 0; + rc = test_tool_simple_schema(); + if (rc != 0) + return rc; + rc = test_prompt_and_resources(); + if (rc != 0) + return rc; + return 0; +} diff --git a/tests/cli/tasks_cli.cpp b/tests/cli/tasks_cli.cpp new file mode 100644 index 0000000..bfd8bfe --- /dev/null +++ b/tests/cli/tasks_cli.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +namespace +{ + +struct CommandResult +{ + int exit_code = -1; + std::string output; +}; + +static std::filesystem::path get_executable_dir(const char* argv0) +{ + std::filesystem::path p = argv0 ? std::filesystem::path(argv0) : std::filesystem::path(); + if (!p.is_absolute()) + p = std::filesystem::absolute(p); + return p.parent_path(); +} + +static std::filesystem::path find_fastmcpp_exe(const char* argv0) +{ + const auto dir = get_executable_dir(argv0); +#if defined(_WIN32) + const auto exe = dir / "fastmcpp.exe"; +#else + const auto exe = dir / "fastmcpp"; +#endif + return exe; +} + +static CommandResult run_capture(const std::string& command) +{ + CommandResult result; + +#if defined(_WIN32) + FILE* pipe = _popen(command.c_str(), "r"); +#else + FILE* pipe = popen(command.c_str(), "r"); +#endif + if (!pipe) + { + result.exit_code = -1; + result.output = "failed to spawn command"; + return result; + } + + std::ostringstream oss; + char buffer[4096]; + while (fgets(buffer, sizeof(buffer), pipe) != nullptr) + oss << buffer; + +#if defined(_WIN32) + int rc = _pclose(pipe); + result.exit_code = rc; +#else + int rc = pclose(pipe); + if (WIFEXITED(rc)) + result.exit_code = WEXITSTATUS(rc); + else + result.exit_code = rc; +#endif + + result.output = oss.str(); + return result; +} + +static bool contains(const std::string& haystack, const std::string& needle) +{ + return haystack.find(needle) != std::string::npos; +} + +static int assert_contains(const std::string& name, const CommandResult& r, int expected_exit, + const std::string& expected_substr) +{ + if (r.exit_code != expected_exit) + { + std::cerr << "[FAIL] " << name << ": exit_code=" << r.exit_code + << " expected=" << expected_exit << "\n" + << r.output << "\n"; + return 1; + } + if (!contains(r.output, expected_substr)) + { + std::cerr << "[FAIL] " << name << ": expected output to contain: " << expected_substr + << "\n" + << r.output << "\n"; + return 1; + } + std::cout << "[OK] " << name << "\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) +{ + const auto fastmcpp_exe = find_fastmcpp_exe(argc > 0 ? argv[0] : nullptr); + if (!std::filesystem::exists(fastmcpp_exe)) + { + std::cerr << "[FAIL] fastmcpp executable not found next to test: " << fastmcpp_exe.string() + << "\n"; + return 1; + } + + const std::string base = "\"" + fastmcpp_exe.string() + "\""; + + // Capture stderr too so we can assert error messages. + const std::string redir = " 2>&1"; + + int failures = 0; + + { + auto r = run_capture(base + " tasks list" + redir); + failures += + assert_contains("tasks list requires connection", r, 2, "Missing connection options"); + } + + { + auto r = run_capture(base + " tasks get --http http://127.0.0.1:1" + redir); + failures += assert_contains("tasks get requires taskId", r, 2, "Missing taskId"); + } + + { + auto r = + run_capture(base + " tasks list --http http://127.0.0.1:1 --not-a-real-flag" + redir); + failures += assert_contains("tasks list rejects unknown flag", r, 2, "Unknown option"); + } + + return failures == 0 ? 0 : 1; +} diff --git a/tests/client/sampling_handlers.cpp b/tests/client/sampling_handlers.cpp new file mode 100644 index 0000000..54e1df5 --- /dev/null +++ b/tests/client/sampling_handlers.cpp @@ -0,0 +1,211 @@ +/// @file tests/client/sampling_handlers.cpp +/// @brief Tests for built-in OpenAI/Anthropic sampling handlers (SEP-1577 follow-up). + +#include "fastmcpp/client/sampling_handlers.hpp" + +#include "fastmcpp/types.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using fastmcpp::Json; + +namespace +{ + +struct LocalServer +{ + httplib::Server server; + int port = -1; + std::thread thread; + + void start() + { + port = server.bind_to_any_port("127.0.0.1", 0); + assert(port > 0); + thread = std::thread([this]() { server.listen_after_bind(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + void stop() + { + server.stop(); + if (thread.joinable()) + thread.join(); + } + + ~LocalServer() + { + stop(); + } +}; + +} // namespace + +int main() +{ + std::cout << "=== sampling handlers tests ===\n\n"; + + LocalServer srv; + std::atomic saw_openai{false}; + std::atomic saw_anthropic{false}; + + srv.server.Post( + "/v1/chat/completions", + [&](const httplib::Request& req, httplib::Response& res) + { + assert(req.has_header("Authorization")); + assert(req.get_header_value("Authorization") == "Bearer testkey"); + + Json body = Json::parse(req.body); + assert(body.value("model", "") == "gpt-test"); + assert(body.contains("messages") && body["messages"].is_array()); + assert(body.contains("tools") && body["tools"].is_array()); + assert(body.value("tool_choice", "") == "required"); + + Json response = { + {"id", "cmpl_test"}, + {"model", "gpt-test"}, + {"choices", + Json::array({Json{ + {"index", 0}, + {"finish_reason", "tool_calls"}, + {"message", + Json{{"role", "assistant"}, + {"content", ""}, + {"tool_calls", + Json::array({Json{ + {"id", "call_1"}, + {"type", "function"}, + {"function", Json{{"name", "add"}, + {"arguments", "{\"a\":10,\"b\":20}"}}}}})}}}}})}, + }; + + res.set_content(response.dump(), "application/json"); + saw_openai = true; + }); + + srv.server.Post("/v1/messages", + [&](const httplib::Request& req, httplib::Response& res) + { + assert(req.has_header("x-api-key")); + assert(req.get_header_value("x-api-key") == "anthropic_testkey"); + assert(req.has_header("anthropic-version")); + + Json body = Json::parse(req.body); + assert(body.value("model", "") == "claude-test"); + assert(body.contains("messages") && body["messages"].is_array()); + + Json response = { + {"id", "msg_test"}, + {"model", "claude-test"}, + {"stop_reason", "end_turn"}, + {"content", Json::array({Json{{"type", "text"}, {"text", "hello"}}})}, + }; + + res.set_content(response.dump(), "application/json"); + saw_anthropic = true; + }); + + srv.start(); + + // OpenAI-compatible handler: tool call => toolUse + try + { + fastmcpp::client::sampling::handlers::OpenAICompatibleOptions opts; + opts.base_url = "http://127.0.0.1:" + std::to_string(srv.port); + opts.default_model = "gpt-test"; + opts.api_key = "testkey"; + opts.timeout_ms = 2000; + + auto cb = + fastmcpp::client::sampling::handlers::create_openai_compatible_sampling_callback(opts); + + Json params = { + {"messages", + Json::array({Json{{"role", "user"}, + {"content", Json{{"type", "text"}, {"text", "Compute"}}}}})}, + {"maxTokens", 64}, + {"tools", + Json::array({Json{ + {"name", "add"}, + {"description", "Add two numbers"}, + {"inputSchema", Json{{"type", "object"}, + {"properties", Json{{"a", Json{{"type", "number"}}}, + {"b", Json{{"type", "number"}}}}}}}}})}, + {"toolChoice", Json{{"mode", "required"}}}, + }; + + Json out = cb(params); + assert(out.value("stopReason", "") == "toolUse"); + assert(out.contains("content") && out["content"].is_array()); + bool found_tool_use = false; + for (const auto& block : out["content"]) + { + if (!block.is_object()) + continue; + if (block.value("type", "") != "tool_use") + continue; + found_tool_use = true; + assert(block.value("id", "") == "call_1"); + assert(block.value("name", "") == "add"); + assert(block.contains("input") && block["input"].is_object()); + assert(block["input"].value("a", 0) == 10); + assert(block["input"].value("b", 0) == 20); + } + assert(found_tool_use); + std::cout << "[OK] OpenAI handler tool calls\n"; + } + catch (const std::exception& e) + { + // If libcurl isn't available in this build, handler factories throw; treat as a skip. + std::cout << "[SKIP] OpenAI handler: " << e.what() << "\n"; + } + + // Anthropic handler: simple text response => endTurn + try + { + fastmcpp::client::sampling::handlers::AnthropicOptions opts; + opts.base_url = "http://127.0.0.1:" + std::to_string(srv.port); + opts.default_model = "claude-test"; + opts.api_key = "anthropic_testkey"; + opts.timeout_ms = 2000; + + auto cb = fastmcpp::client::sampling::handlers::create_anthropic_sampling_callback(opts); + + Json params = { + {"messages", + Json::array( + {Json{{"role", "user"}, {"content", Json{{"type", "text"}, {"text", "Hello"}}}}})}, + {"maxTokens", 64}, + }; + + Json out = cb(params); + assert(out.value("stopReason", "") == "endTurn"); + assert(out.contains("content") && out["content"].is_array()); + assert(out["content"].size() >= 1); + assert(out["content"][0].value("type", "") == "text"); + assert(out["content"][0].value("text", "") == "hello"); + std::cout << "[OK] Anthropic handler text\n"; + } + catch (const std::exception& e) + { + std::cout << "[SKIP] Anthropic handler: " << e.what() << "\n"; + } + + // If both handlers were supposed to run (libcurl available), ensure we actually hit the server. + // If they were skipped due to missing libcurl, both flags will stay false. + if (saw_openai || saw_anthropic) + { + assert(saw_openai); + assert(saw_anthropic); + } + + std::cout << "\n[OK] sampling handlers tests complete\n"; + return 0; +}