From cf17d3c38b88c9c16bab0efff54ee01c0d72f09f Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sun, 15 Feb 2026 07:39:46 -0800 Subject: [PATCH 1/6] feat: fastmcpp 3.x parity sync (squashed) --- CMakeLists.txt | 101 +- README.md | 10 +- examples/mcp_apps.cpp | 60 + examples/openapi_provider.cpp | 38 + examples/response_limiting_middleware.cpp | 34 + examples/skills_provider.cpp | 36 + include/fastmcpp.hpp | 5 + include/fastmcpp/app.hpp | 21 +- include/fastmcpp/client/client.hpp | 6 + include/fastmcpp/client/transports.hpp | 31 +- include/fastmcpp/client/types.hpp | 45 +- include/fastmcpp/prompts/prompt.hpp | 1 + .../fastmcpp/providers/openapi_provider.hpp | 60 + include/fastmcpp/providers/provider.hpp | 7 +- .../fastmcpp/providers/skills_provider.hpp | 152 ++ .../providers/transforms/prompts_as_tools.hpp | 37 + .../transforms/resources_as_tools.hpp | 48 + .../providers/transforms/version_filter.hpp | 43 + include/fastmcpp/proxy.hpp | 2 +- include/fastmcpp/resources/resource.hpp | 2 + include/fastmcpp/resources/template.hpp | 2 + include/fastmcpp/server/context.hpp | 64 +- include/fastmcpp/server/ping_middleware.hpp | 33 + .../server/response_limiting_middleware.hpp | 30 + include/fastmcpp/server/session.hpp | 12 + include/fastmcpp/tools/manager.hpp | 5 +- include/fastmcpp/tools/tool.hpp | 53 +- include/fastmcpp/tools/tool_transform.hpp | 6 +- include/fastmcpp/types.hpp | 71 + include/fastmcpp/util/json_schema.hpp | 2 + include/fastmcpp/util/pagination.hpp | 142 ++ src/app.cpp | 124 +- src/cli/main.cpp | 1460 +++++++++++++++-- src/client/transports.cpp | 109 +- src/mcp/handler.cpp | 377 +++-- src/providers/openapi_provider.cpp | 465 ++++++ src/providers/skills_provider.cpp | 592 +++++++ src/providers/transforms/prompts_as_tools.cpp | 104 ++ .../transforms/resources_as_tools.cpp | 91 + src/providers/transforms/version_filter.cpp | 182 ++ src/proxy.cpp | 20 +- src/resources/template.cpp | 1 + src/server/context.cpp | 5 +- src/server/ping_middleware.cpp | 36 + src/server/response_limiting_middleware.cpp | 73 + src/util/json_schema.cpp | 119 +- tests/app/mcp_apps.cpp | 311 ++++ tests/cli/generated_cli_e2e.cpp | 283 ++++ tests/cli/tasks_cli.cpp | 243 +++ tests/mcp/test_error_codes.cpp | 89 + tests/mcp/test_pagination.cpp | 111 ++ tests/providers/openapi_provider.cpp | 157 ++ tests/providers/skills_provider.cpp | 165 ++ tests/providers/version_filter.cpp | 131 ++ tests/proxy/basic.cpp | 12 +- tests/schema/dereference_toggle.cpp | 139 ++ tests/server/streamable_http_integration.cpp | 62 + tests/server/test_response_limiting.cpp | 158 ++ tests/server/test_server_session.cpp | 21 + tests/server/test_session_state.cpp | 137 ++ tests/tools/test_tool_manager.cpp | 4 +- tests/tools/test_tool_sequential.cpp | 85 + tests/tools/test_tool_transform_enabled.cpp | 97 ++ tests/transports/ws_streaming.cpp | 41 - tests/transports/ws_streaming_local.cpp | 94 -- 65 files changed, 6719 insertions(+), 538 deletions(-) create mode 100644 examples/mcp_apps.cpp create mode 100644 examples/openapi_provider.cpp create mode 100644 examples/response_limiting_middleware.cpp create mode 100644 examples/skills_provider.cpp create mode 100644 include/fastmcpp/providers/openapi_provider.hpp create mode 100644 include/fastmcpp/providers/skills_provider.hpp create mode 100644 include/fastmcpp/providers/transforms/prompts_as_tools.hpp create mode 100644 include/fastmcpp/providers/transforms/resources_as_tools.hpp create mode 100644 include/fastmcpp/providers/transforms/version_filter.hpp create mode 100644 include/fastmcpp/server/ping_middleware.hpp create mode 100644 include/fastmcpp/server/response_limiting_middleware.hpp create mode 100644 include/fastmcpp/util/pagination.hpp create mode 100644 src/providers/openapi_provider.cpp create mode 100644 src/providers/skills_provider.cpp create mode 100644 src/providers/transforms/prompts_as_tools.cpp create mode 100644 src/providers/transforms/resources_as_tools.cpp create mode 100644 src/providers/transforms/version_filter.cpp create mode 100644 src/server/ping_middleware.cpp create mode 100644 src/server/response_limiting_middleware.cpp create mode 100644 tests/app/mcp_apps.cpp create mode 100644 tests/cli/generated_cli_e2e.cpp create mode 100644 tests/mcp/test_error_codes.cpp create mode 100644 tests/mcp/test_pagination.cpp create mode 100644 tests/providers/openapi_provider.cpp create mode 100644 tests/providers/skills_provider.cpp create mode 100644 tests/providers/version_filter.cpp create mode 100644 tests/schema/dereference_toggle.cpp create mode 100644 tests/server/test_response_limiting.cpp create mode 100644 tests/server/test_session_state.cpp create mode 100644 tests/tools/test_tool_sequential.cpp create mode 100644 tests/tools/test_tool_transform_enabled.cpp delete mode 100644 tests/transports/ws_streaming.cpp delete mode 100644 tests/transports/ws_streaming_local.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index eb1ca63..871486f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,8 +14,6 @@ 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) add_library(fastmcpp_core STATIC src/types.cpp @@ -24,7 +22,12 @@ add_library(fastmcpp_core STATIC src/providers/transforms/namespace.cpp src/providers/transforms/tool_transform.cpp src/providers/transforms/visibility.cpp + src/providers/transforms/version_filter.cpp + src/providers/transforms/prompts_as_tools.cpp + src/providers/transforms/resources_as_tools.cpp src/providers/filesystem_provider.cpp + src/providers/skills_provider.cpp + src/providers/openapi_provider.cpp src/proxy.cpp src/mcp/handler.cpp src/mcp/tasks.cpp @@ -40,6 +43,8 @@ add_library(fastmcpp_core STATIC src/server/context.cpp src/server/middleware.cpp src/server/security_middleware.cpp + src/server/response_limiting_middleware.cpp + src/server/ping_middleware.cpp src/server/sampling.cpp src/server/http_server.cpp src/server/stdio_server.cpp @@ -92,19 +97,6 @@ if(NOT cpp_httplib_POPULATED) endif() target_include_directories(fastmcpp_core PUBLIC ${cpp_httplib_SOURCE_DIR}) -# Header-only WebSocket client (easywsclient) -FetchContent_Declare( - easywsclient - GIT_REPOSITORY https://github.com/dhbaird/easywsclient.git - GIT_TAG master -) -FetchContent_GetProperties(easywsclient) -if(NOT easywsclient_POPULATED) - FetchContent_Populate(easywsclient) -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 and sampling handlers (modular) if(FASTMCPP_ENABLE_POST_STREAMING OR FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS) if(FASTMCPP_FETCH_CURL) @@ -248,6 +240,9 @@ if(FASTMCPP_BUILD_TESTS) 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_cli_generated_e2e tests/cli/generated_cli_e2e.cpp) + target_link_libraries(fastmcpp_cli_generated_e2e PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_cli_generated_e2e COMMAND fastmcpp_cli_generated_e2e) add_executable(fastmcpp_http_integration tests/server/http_integration.cpp) target_link_libraries(fastmcpp_http_integration PRIVATE fastmcpp_core) @@ -273,6 +268,10 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_schema_build PRIVATE fastmcpp_core) add_test(NAME fastmcpp_schema_build COMMAND fastmcpp_schema_build) + add_executable(fastmcpp_schema_dereference_toggle tests/schema/dereference_toggle.cpp) + 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_content tests/content.cpp) target_link_libraries(fastmcpp_content PRIVATE fastmcpp_core) add_test(NAME fastmcpp_content COMMAND fastmcpp_content) @@ -477,6 +476,11 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_app_ergonomics PRIVATE fastmcpp_core) add_test(NAME fastmcpp_app_ergonomics COMMAND fastmcpp_app_ergonomics) + # MCP Apps metadata parity tests + add_executable(fastmcpp_app_mcp_apps tests/app/mcp_apps.cpp) + target_link_libraries(fastmcpp_app_mcp_apps PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_app_mcp_apps COMMAND fastmcpp_app_mcp_apps) + # Filesystem provider tests add_library(fastmcpp_fs_test_plugin SHARED tests/fs/test_plugin.cpp) target_compile_definitions(fastmcpp_fs_test_plugin PRIVATE FASTMCPP_PROVIDER_EXPORTS) @@ -497,6 +501,18 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_provider_transforms PRIVATE fastmcpp_core) add_test(NAME fastmcpp_provider_transforms COMMAND fastmcpp_provider_transforms) + add_executable(fastmcpp_provider_version_filter tests/providers/version_filter.cpp) + target_link_libraries(fastmcpp_provider_version_filter PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_provider_version_filter COMMAND fastmcpp_provider_version_filter) + + add_executable(fastmcpp_provider_skills tests/providers/skills_provider.cpp) + target_link_libraries(fastmcpp_provider_skills PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_provider_skills COMMAND fastmcpp_provider_skills) + + add_executable(fastmcpp_provider_openapi tests/providers/openapi_provider.cpp) + target_link_libraries(fastmcpp_provider_openapi PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_provider_openapi COMMAND fastmcpp_provider_openapi) + # Proxy tests add_executable(fastmcpp_proxy_basic tests/proxy/basic.cpp) target_link_libraries(fastmcpp_proxy_basic PRIVATE fastmcpp_core) @@ -507,6 +523,31 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_main_header PRIVATE fastmcpp_core) add_test(NAME fastmcpp_main_header COMMAND fastmcpp_main_header) + # Sync-cycle tests (Phase 11) + add_executable(fastmcpp_mcp_error_codes tests/mcp/test_error_codes.cpp) + target_link_libraries(fastmcpp_mcp_error_codes PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_mcp_error_codes COMMAND fastmcpp_mcp_error_codes) + + add_executable(fastmcpp_pagination tests/mcp/test_pagination.cpp) + target_link_libraries(fastmcpp_pagination PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_pagination COMMAND fastmcpp_pagination) + + add_executable(fastmcpp_tools_transform_enabled tests/tools/test_tool_transform_enabled.cpp) + target_link_libraries(fastmcpp_tools_transform_enabled PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_tools_transform_enabled COMMAND fastmcpp_tools_transform_enabled) + + add_executable(fastmcpp_tools_sequential tests/tools/test_tool_sequential.cpp) + target_link_libraries(fastmcpp_tools_sequential PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_tools_sequential COMMAND fastmcpp_tools_sequential) + + add_executable(fastmcpp_session_state tests/server/test_session_state.cpp) + target_link_libraries(fastmcpp_session_state PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_session_state COMMAND fastmcpp_session_state) + + add_executable(fastmcpp_response_limiting tests/server/test_response_limiting.cpp) + target_link_libraries(fastmcpp_response_limiting PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_response_limiting COMMAND fastmcpp_response_limiting) + set_tests_properties(fastmcpp_stdio_client PROPERTIES LABELS "conformance" WORKING_DIRECTORY "$" @@ -527,20 +568,6 @@ if(FASTMCPP_BUILD_TESTS) set_tests_properties(fastmcpp_streaming_sse PROPERTIES RUN_SERIAL TRUE) endif() - if(FASTMCPP_ENABLE_WS_STREAMING_TESTS) - 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) - 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) @@ -585,6 +612,18 @@ if(FASTMCPP_BUILD_EXAMPLES) add_executable(fastmcpp_example_tags_example examples/tags_example.cpp) target_link_libraries(fastmcpp_example_tags_example PRIVATE fastmcpp_core) + # MCP Apps example (FastMCP 3.x surface) + add_executable(fastmcpp_example_mcp_apps examples/mcp_apps.cpp) + target_link_libraries(fastmcpp_example_mcp_apps PRIVATE fastmcpp_core) + + # Skills provider example + add_executable(fastmcpp_example_skills_provider examples/skills_provider.cpp) + target_link_libraries(fastmcpp_example_skills_provider PRIVATE fastmcpp_core) + + # OpenAPI provider example + add_executable(fastmcpp_example_openapi_provider examples/openapi_provider.cpp) + target_link_libraries(fastmcpp_example_openapi_provider PRIVATE fastmcpp_core) + # Context API example (v2.13.0+) add_executable(fastmcpp_example_context_introspection examples/context_introspection.cpp) target_link_libraries(fastmcpp_example_context_introspection PRIVATE fastmcpp_core) @@ -605,6 +644,10 @@ if(FASTMCPP_BUILD_EXAMPLES) add_executable(fastmcpp_example_tool_injection_middleware examples/tool_injection_middleware.cpp) target_link_libraries(fastmcpp_example_tool_injection_middleware PRIVATE fastmcpp_core) + # Response Limiting Middleware example + add_executable(fastmcpp_example_response_limiting_middleware examples/response_limiting_middleware.cpp) + target_link_libraries(fastmcpp_example_response_limiting_middleware PRIVATE fastmcpp_core) + # Server Metadata example (v2.13.0+) add_executable(fastmcpp_example_server_metadata examples/server_metadata.cpp) target_link_libraries(fastmcpp_example_server_metadata PRIVATE fastmcpp_core) diff --git a/README.md b/README.md index 3a56838..c2dbb77 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ --- -fastmcpp is a C++ port of the Python [fastmcp](https://github.com/jlowin/fastmcp) library, providing native performance for MCP servers and clients with support for tools, resources, prompts, and multiple transport layers (STDIO, HTTP/SSE, WebSocket). +fastmcpp is a C++ port of the Python [fastmcp](https://github.com/jlowin/fastmcp) library, providing native performance for MCP servers and clients with support for tools, resources, prompts, and MCP-standard transport layers (STDIO, HTTP/SSE, Streamable HTTP). **Status:** Beta – core MCP features track the Python `fastmcp` reference. @@ -20,7 +20,7 @@ fastmcpp is a C++ port of the Python [fastmcp](https://github.com/jlowin/fastmcp ## Features - Core MCP protocol implementation (JSON‑RPC). -- Multiple transports: STDIO, HTTP (SSE), Streamable HTTP, WebSocket. +- Multiple transports: STDIO, HTTP (SSE), Streamable HTTP. - Streamable HTTP transport (MCP spec 2025-03-26) with session management. - Tool management and invocation. - Resources and prompts support. @@ -47,7 +47,6 @@ Optional: - libcurl (for HTTP POST streaming; can be fetched when `FASTMCPP_FETCH_CURL=ON`). - cpp‑httplib (HTTP server, fetched automatically). -- easywsclient (WebSocket client, fetched automatically). ## Building @@ -68,8 +67,7 @@ cmake -B build -S . \ -DCMAKE_BUILD_TYPE=Release \ -DFASTMCPP_ENABLE_POST_STREAMING=ON \ -DFASTMCPP_FETCH_CURL=ON \ - -DFASTMCPP_ENABLE_STREAMING_TESTS=ON \ - -DFASTMCPP_ENABLE_WS_STREAMING_TESTS=ON + -DFASTMCPP_ENABLE_STREAMING_TESTS=ON ``` Key options: @@ -80,7 +78,6 @@ Key options: | `FASTMCPP_ENABLE_POST_STREAMING` | OFF | Enable HTTP POST streaming (requires libcurl) | | `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 | ### Platform notes @@ -276,7 +273,6 @@ int main() { The `create_proxy()` factory function automatically detects the transport type from the URL: - `http://` or `https://` URLs use HTTP transport -- `ws://` or `wss://` URLs use WebSocket transport Local tools, resources, and prompts take precedence over remote ones with the same name. diff --git a/examples/mcp_apps.cpp b/examples/mcp_apps.cpp new file mode 100644 index 0000000..ddf475c --- /dev/null +++ b/examples/mcp_apps.cpp @@ -0,0 +1,60 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/mcp/handler.hpp" +#include "fastmcpp/server/stdio_server.hpp" + +#include + +int main() +{ + using fastmcpp::AppConfig; + using fastmcpp::FastMCP; + using fastmcpp::Json; + + FastMCP app("mcp_apps_example", "1.0.0"); + + // Tool with MCP Apps metadata under _meta.ui (resourceUri + visibility) + FastMCP::ToolOptions tool_opts; + AppConfig tool_ui; + tool_ui.resource_uri = "ui://widgets/echo.html"; + tool_ui.visibility = std::vector{"tool_result"}; + tool_opts.app = tool_ui; + + app.tool("echo_ui", [](const Json& in) { return in; }, tool_opts); + + // UI resource: mime_type defaults to text/html;profile=mcp-app for ui:// URIs + FastMCP::ResourceOptions resource_opts; + AppConfig resource_ui; + resource_ui.domain = "https://example.local"; + resource_ui.prefers_border = true; + resource_opts.app = resource_ui; + + app.resource("ui://widgets/home.html", "Home Widget", + [](const Json&) + { + return fastmcpp::resources::ResourceContent{ + "ui://widgets/home.html", std::nullopt, + std::string{"

Home

"}}; + }, + resource_opts); + + // UI resource template with per-template metadata + FastMCP::ResourceTemplateOptions templ_opts; + AppConfig templ_ui; + templ_ui.csp = Json{{"connectDomains", Json::array({"https://api.example.test"})}}; + templ_opts.app = templ_ui; + + app.resource_template("ui://widgets/{name}.html", "Named Widget", + [](const Json& params) + { + const std::string name = params.value("name", "unknown"); + return fastmcpp::resources::ResourceContent{ + "ui://widgets/" + name + ".html", std::nullopt, + std::string{"

" + name + "

"}}; + }, + Json::object(), templ_opts); + + auto handler = fastmcpp::mcp::make_mcp_handler(app); + fastmcpp::server::StdioServerWrapper server(handler); + server.run(); + return 0; +} diff --git a/examples/openapi_provider.cpp b/examples/openapi_provider.cpp new file mode 100644 index 0000000..ba0005d --- /dev/null +++ b/examples/openapi_provider.cpp @@ -0,0 +1,38 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/providers/openapi_provider.hpp" + +#include +#include + +int main() +{ + using namespace fastmcpp; + + Json spec = Json::object(); + spec["openapi"] = "3.0.3"; + spec["info"] = Json{{"title", "Example API"}, {"version", "1.0.0"}}; + spec["servers"] = Json::array({Json{{"url", "http://127.0.0.1:8080"}}}); + spec["paths"] = Json::object(); + spec["paths"]["/status"]["get"] = Json{ + {"operationId", "getStatus"}, + {"responses", + Json{{"200", + Json{{"description", "ok"}, + {"content", + Json{{"application/json", + Json{{"schema", + Json{{"type", "object"}, + {"properties", + Json{{"status", Json{{"type", "string"}}}}}}}}}}}}}}}, + }; + + FastMCP app("openapi-provider-example", "1.0.0"); + auto provider = std::make_shared(spec); + app.add_provider(provider); + + std::cout << "OpenAPI tools discovered:\n"; + for (const auto& tool : app.list_all_tools_info()) + std::cout << " - " << tool.name << "\n"; + std::cout << "Run a compatible HTTP server at http://127.0.0.1:8080 to invoke these tools.\n"; + return 0; +} diff --git a/examples/response_limiting_middleware.cpp b/examples/response_limiting_middleware.cpp new file mode 100644 index 0000000..06bdd15 --- /dev/null +++ b/examples/response_limiting_middleware.cpp @@ -0,0 +1,34 @@ +#include "fastmcpp/server/response_limiting_middleware.hpp" +#include "fastmcpp/server/server.hpp" + +#include +#include + +using namespace fastmcpp; + +int main() +{ + server::Server srv("response_limiting_demo", "1.0.0"); + + srv.route("tools/call", + [](const Json& payload) + { + Json args = payload.value("arguments", Json::object()); + std::string text = args.value("text", std::string(120, 'A')); + return Json{ + {"content", Json::array({{{"type", "text"}, {"text", text}}})}, + }; + }); + + server::ResponseLimitingMiddleware limiter(48, "... [truncated]"); + srv.add_after(limiter.make_hook()); + + Json req = {{"name", "echo_large"}, + {"arguments", + {{"text", + "This response is intentionally long so middleware truncation is easy to see."}}}}; + + auto resp = srv.handle("tools/call", req); + std::cout << resp.dump(2) << "\n"; + return 0; +} diff --git a/examples/skills_provider.cpp b/examples/skills_provider.cpp new file mode 100644 index 0000000..6f4ade0 --- /dev/null +++ b/examples/skills_provider.cpp @@ -0,0 +1,36 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/providers/skills_provider.hpp" + +#include +#include +#include +#include + +int main() +{ + using namespace fastmcpp; + + FastMCP app("skills-provider-example", "1.0.0"); + auto skills_root = std::filesystem::path(std::getenv("USERPROFILE") ? std::getenv("USERPROFILE") : "") / + ".codex" / "skills"; + + try + { + auto provider = std::make_shared( + std::vector{skills_root}, false, "SKILL.md", + providers::SkillSupportingFiles::Template); + app.add_provider(provider); + } + catch (const std::exception& e) + { + std::cerr << "Failed to initialize skills provider: " << e.what() << "\n"; + return 1; + } + + auto resources = app.list_all_resources(); + auto templates = app.list_all_templates(); + + std::cout << "Loaded skills resources: " << resources.size() << "\n"; + std::cout << "Loaded skills templates: " << templates.size() << "\n"; + return 0; +} diff --git a/include/fastmcpp.hpp b/include/fastmcpp.hpp index eafde11..1cf7951 100644 --- a/include/fastmcpp.hpp +++ b/include/fastmcpp.hpp @@ -42,6 +42,11 @@ // Tools, Resources, Prompts #include "fastmcpp/prompts/manager.hpp" #include "fastmcpp/prompts/prompt.hpp" +#include "fastmcpp/providers/filesystem_provider.hpp" +#include "fastmcpp/providers/local_provider.hpp" +#include "fastmcpp/providers/openapi_provider.hpp" +#include "fastmcpp/providers/skills_provider.hpp" +#include "fastmcpp/providers/transforms/version_filter.hpp" #include "fastmcpp/resources/manager.hpp" #include "fastmcpp/resources/resource.hpp" #include "fastmcpp/tools/manager.hpp" diff --git a/include/fastmcpp/app.hpp b/include/fastmcpp/app.hpp index 3df30d5..d5b6e3b 100644 --- a/include/fastmcpp/app.hpp +++ b/include/fastmcpp/app.hpp @@ -65,6 +65,7 @@ class FastMCP public: struct ToolOptions { + std::optional version; std::optional title; std::optional description; std::optional> icons; @@ -72,10 +73,13 @@ class FastMCP TaskSupport task_support{TaskSupport::Forbidden}; Json output_schema{Json::object()}; std::optional timeout; + bool sequential{false}; + std::optional app; }; struct PromptOptions { + std::optional version; std::optional description; std::optional meta; std::vector arguments; @@ -84,29 +88,34 @@ class FastMCP struct ResourceOptions { + std::optional version; std::optional description; std::optional mime_type; std::optional title; std::optional annotations; std::optional> icons; TaskSupport task_support{TaskSupport::Forbidden}; + std::optional app; }; struct ResourceTemplateOptions { + std::optional version; std::optional description; std::optional mime_type; std::optional title; std::optional annotations; std::optional> icons; TaskSupport task_support{TaskSupport::Forbidden}; + std::optional app; }; /// Construct app with metadata explicit FastMCP(std::string name = "fastmcpp_app", std::string version = "1.0.0", std::optional website_url = std::nullopt, std::optional> icons = std::nullopt, - std::vector> providers = {}); + std::vector> providers = {}, + int list_page_size = 0, bool dereference_schemas = true); // Metadata accessors const std::string& name() const @@ -125,6 +134,14 @@ class FastMCP { return server_.icons(); } + int list_page_size() const + { + return list_page_size_; + } + bool dereference_schemas() const + { + return dereference_schemas_; + } // Manager accessors tools::ToolManager& tools() @@ -293,6 +310,8 @@ class FastMCP std::vector proxy_mounted_; mutable std::vector provider_tools_cache_; mutable std::vector provider_prompts_cache_; + int list_page_size_{0}; + bool dereference_schemas_{true}; // Prefix utilities static std::string add_prefix(const std::string& name, const std::string& prefix); diff --git a/include/fastmcpp/client/client.hpp b/include/fastmcpp/client/client.hpp index 7aaecac..cb7d57f 100644 --- a/include/fastmcpp/client/client.hpp +++ b/include/fastmcpp/client/client.hpp @@ -1175,8 +1175,14 @@ class Client result.capabilities.prompts = caps["prompts"]; if (caps.contains("resources")) result.capabilities.resources = caps["resources"]; + if (caps.contains("sampling")) + result.capabilities.sampling = caps["sampling"]; + if (caps.contains("tasks")) + result.capabilities.tasks = caps["tasks"]; if (caps.contains("tools")) result.capabilities.tools = caps["tools"]; + if (caps.contains("extensions")) + result.capabilities.extensions = caps["extensions"]; } if (response.contains("serverInfo")) diff --git a/include/fastmcpp/client/transports.hpp b/include/fastmcpp/client/transports.hpp index f2dde60..d842dd4 100644 --- a/include/fastmcpp/client/transports.hpp +++ b/include/fastmcpp/client/transports.hpp @@ -3,6 +3,7 @@ #include "fastmcpp/types.hpp" #include +#include #include #include #include @@ -23,7 +24,12 @@ class ITransport; class HttpTransport : public ITransport { public: - explicit HttpTransport(std::string base_url) : base_url_(std::move(base_url)) {} + explicit HttpTransport(std::string base_url, + std::chrono::seconds timeout = std::chrono::seconds(300), + std::unordered_map headers = {}) + : base_url_(std::move(base_url)), timeout_(timeout), headers_(std::move(headers)) + { + } fastmcpp::Json request(const std::string& route, const fastmcpp::Json& payload); // Optional streaming parity: receive SSE/stream-like responses void request_stream(const std::string& route, const fastmcpp::Json& payload, @@ -33,24 +39,15 @@ class HttpTransport : public ITransport void request_stream_post(const std::string& route, const fastmcpp::Json& payload, const std::function& on_event); - private: - std::string base_url_; -}; - -class WebSocketTransport : public ITransport -{ - public: - explicit WebSocketTransport(std::string url) : url_(std::move(url)) {} - fastmcpp::Json request(const std::string& /*route*/, const fastmcpp::Json& /*payload*/); - - // Stream responses over WebSocket. Sends payload, then dispatches - // incoming text frames to the callback as parsed JSON if possible, - // otherwise as a text content wrapper {"content":[{"type":"text","text":...}]}. - void request_stream(const std::string& route, const fastmcpp::Json& payload, - const std::function& on_event); + std::chrono::seconds timeout() const + { + return timeout_; + } private: - std::string url_; + std::string base_url_; + std::chrono::seconds timeout_; + std::unordered_map headers_; }; // Launches an MCP stdio server as a subprocess and performs JSON-RPC requests diff --git a/include/fastmcpp/client/types.hpp b/include/fastmcpp/client/types.hpp index 0d1fe1d..c2c91d6 100644 --- a/include/fastmcpp/client/types.hpp +++ b/include/fastmcpp/client/types.hpp @@ -62,6 +62,7 @@ struct ToolInfo std::optional outputSchema; ///< JSON Schema for structured output std::optional execution; ///< Execution config (SEP-1686) std::optional> icons; ///< Icons for UI display + std::optional app; ///< MCP Apps metadata (_meta.ui) std::optional _meta; ///< Protocol metadata }; @@ -129,6 +130,7 @@ struct ResourceInfo std::optional mimeType; std::optional annotations; std::optional> icons; ///< Icons for UI display + std::optional app; ///< MCP Apps metadata (_meta.ui) std::optional _meta; ///< Protocol metadata }; @@ -144,6 +146,7 @@ struct ResourceTemplate std::optional parameters; ///< JSON Schema for template parameters std::optional annotations; std::optional> icons; ///< Icons for UI display + std::optional app; ///< MCP Apps metadata (_meta.ui) std::optional _meta; ///< Protocol metadata }; @@ -153,6 +156,7 @@ struct TextResourceContent std::string uri; std::optional mimeType; std::string text; + std::optional _meta; }; /// Binary resource content @@ -161,6 +165,7 @@ struct BlobResourceContent std::string uri; std::optional mimeType; std::string blob; ///< Base64-encoded binary data + std::optional _meta; }; /// Resource content variant @@ -273,7 +278,10 @@ struct ServerCapabilities std::optional logging; std::optional prompts; std::optional resources; + std::optional sampling; + std::optional tasks; std::optional tools; + std::optional extensions; }; /// Server information @@ -333,8 +341,11 @@ inline void to_json(fastmcpp::Json& j, const ToolInfo& t) j["execution"] = *t.execution; if (t.icons) j["icons"] = *t.icons; - if (t._meta) - j["_meta"] = *t._meta; + fastmcpp::Json meta = t._meta && t._meta->is_object() ? *t._meta : fastmcpp::Json::object(); + if (t.app) + meta["ui"] = *t.app; + if (!meta.empty()) + j["_meta"] = std::move(meta); } inline void from_json(const fastmcpp::Json& j, ToolInfo& t) @@ -352,7 +363,11 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t) if (j.contains("icons")) t.icons = j["icons"].get>(); if (j.contains("_meta")) + { t._meta = j["_meta"]; + if (j["_meta"].is_object() && j["_meta"].contains("ui") && j["_meta"]["ui"].is_object()) + t.app = j["_meta"]["ui"].get(); + } } inline void to_json(fastmcpp::Json& j, const ResourceInfo& r) @@ -368,8 +383,11 @@ inline void to_json(fastmcpp::Json& j, const ResourceInfo& r) j["annotations"] = *r.annotations; if (r.icons) j["icons"] = *r.icons; - if (r._meta) - j["_meta"] = *r._meta; + fastmcpp::Json meta = r._meta && r._meta->is_object() ? *r._meta : fastmcpp::Json::object(); + if (r.app) + meta["ui"] = *r.app; + if (!meta.empty()) + j["_meta"] = std::move(meta); } inline void from_json(const fastmcpp::Json& j, ResourceInfo& r) @@ -387,7 +405,11 @@ inline void from_json(const fastmcpp::Json& j, ResourceInfo& r) if (j.contains("icons")) r.icons = j["icons"].get>(); if (j.contains("_meta")) + { r._meta = j["_meta"]; + if (j["_meta"].is_object() && j["_meta"].contains("ui") && j["_meta"]["ui"].is_object()) + r.app = j["_meta"]["ui"].get(); + } } inline void to_json(fastmcpp::Json& j, const ResourceTemplate& t) @@ -405,8 +427,11 @@ inline void to_json(fastmcpp::Json& j, const ResourceTemplate& t) j["annotations"] = *t.annotations; if (t.icons) j["icons"] = *t.icons; - if (t._meta) - j["_meta"] = *t._meta; + fastmcpp::Json meta = t._meta && t._meta->is_object() ? *t._meta : fastmcpp::Json::object(); + if (t.app) + meta["ui"] = *t.app; + if (!meta.empty()) + j["_meta"] = std::move(meta); } inline void from_json(const fastmcpp::Json& j, ResourceTemplate& t) @@ -426,7 +451,11 @@ inline void from_json(const fastmcpp::Json& j, ResourceTemplate& t) if (j.contains("icons")) t.icons = j["icons"].get>(); if (j.contains("_meta")) + { t._meta = j["_meta"]; + if (j["_meta"].is_object() && j["_meta"].contains("ui") && j["_meta"]["ui"].is_object()) + t.app = j["_meta"]["ui"].get(); + } } inline void to_json(fastmcpp::Json& j, const PromptInfo& p) @@ -485,6 +514,8 @@ inline void from_json(const fastmcpp::Json& j, TextResourceContent& c) if (j.contains("mimeType")) c.mimeType = j["mimeType"].get(); c.text = j.at("text").get(); + if (j.contains("_meta")) + c._meta = j["_meta"]; } inline void from_json(const fastmcpp::Json& j, BlobResourceContent& c) @@ -493,6 +524,8 @@ inline void from_json(const fastmcpp::Json& j, BlobResourceContent& c) if (j.contains("mimeType")) c.mimeType = j["mimeType"].get(); c.blob = j.at("blob").get(); + if (j.contains("_meta")) + c._meta = j["_meta"]; } /// Parse a content block from JSON diff --git a/include/fastmcpp/prompts/prompt.hpp b/include/fastmcpp/prompts/prompt.hpp index 03bef18..158a2ee 100644 --- a/include/fastmcpp/prompts/prompt.hpp +++ b/include/fastmcpp/prompts/prompt.hpp @@ -37,6 +37,7 @@ struct PromptResult struct Prompt { std::string name; + std::optional version; std::optional description; std::optional meta; // Optional prompt metadata (returned as _meta in prompts/get) diff --git a/include/fastmcpp/providers/openapi_provider.hpp b/include/fastmcpp/providers/openapi_provider.hpp new file mode 100644 index 0000000..4b7514d --- /dev/null +++ b/include/fastmcpp/providers/openapi_provider.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "fastmcpp/providers/provider.hpp" + +#include +#include +#include +#include + +namespace fastmcpp::providers +{ + +class OpenAPIProvider : public Provider +{ + public: + struct Options + { + bool validate_output{true}; + std::map mcp_names; // operationId -> component name + }; + + explicit OpenAPIProvider(Json openapi_spec, std::optional base_url = std::nullopt, + Options options = {}); + + static OpenAPIProvider from_file(const std::string& file_path, + std::optional base_url = std::nullopt, + Options options = {}); + + std::vector list_tools() const override; + std::optional get_tool(const std::string& name) const override; + + private: + struct RouteDefinition + { + std::string tool_name; + std::string method; + std::string path; + Json input_schema; + Json output_schema; + std::vector path_params; + std::vector query_params; + bool has_json_body{false}; + std::optional description; + }; + + static std::string slugify(const std::string& text); + static std::string normalize_method(const std::string& method); + + Json invoke_route(const RouteDefinition& route, const Json& arguments) const; + std::vector parse_routes() const; + + Json openapi_spec_; + std::string base_url_; + std::optional spec_version_; + Options options_; + std::vector routes_; + std::vector tools_; +}; + +} // namespace fastmcpp::providers diff --git a/include/fastmcpp/providers/provider.hpp b/include/fastmcpp/providers/provider.hpp index c5bcca8..b6d0cd9 100644 --- a/include/fastmcpp/providers/provider.hpp +++ b/include/fastmcpp/providers/provider.hpp @@ -8,6 +8,7 @@ #include "fastmcpp/resources/template.hpp" #include "fastmcpp/tools/tool.hpp" +#include #include #include #include @@ -56,7 +57,11 @@ class Provider auto next = chain; chain = [transform, next]() { return transform->list_tools(next); }; } - return chain(); + auto result = chain(); + result.erase(std::remove_if(result.begin(), result.end(), + [](const tools::Tool& t) { return t.is_hidden(); }), + result.end()); + return result; } std::optional get_tool_transformed(const std::string& name) const diff --git a/include/fastmcpp/providers/skills_provider.hpp b/include/fastmcpp/providers/skills_provider.hpp new file mode 100644 index 0000000..955384f --- /dev/null +++ b/include/fastmcpp/providers/skills_provider.hpp @@ -0,0 +1,152 @@ +#pragma once + +#include "fastmcpp/providers/provider.hpp" + +#include +#include +#include +#include +#include + +namespace fastmcpp::providers +{ + +enum class SkillSupportingFiles +{ + Template, + Resources, +}; + +class SkillProvider : public Provider +{ + public: + explicit SkillProvider(std::filesystem::path skill_path, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + + std::vector list_resources() const override; + std::optional get_resource(const std::string& uri) const override; + + std::vector list_resource_templates() const override; + std::optional + get_resource_template(const std::string& uri) const override; + + const std::filesystem::path& skill_path() const + { + return skill_path_; + } + const std::string& skill_name() const + { + return skill_name_; + } + + private: + std::string build_description() const; + std::string build_manifest_json() const; + std::vector list_files() const; + + std::filesystem::path skill_path_; + std::string skill_name_; + std::string main_file_name_; + SkillSupportingFiles supporting_files_; +}; + +class SkillsDirectoryProvider : public Provider +{ + public: + explicit SkillsDirectoryProvider(std::filesystem::path root, bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = + SkillSupportingFiles::Template); + + explicit SkillsDirectoryProvider(std::vector roots, bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = + SkillSupportingFiles::Template); + + std::vector list_resources() const override; + std::optional get_resource(const std::string& uri) const override; + + std::vector list_resource_templates() const override; + std::optional + get_resource_template(const std::string& uri) const override; + + private: + void ensure_discovered() const; + void discover_skills() const; + + std::vector roots_; + bool reload_{false}; + std::string main_file_name_; + SkillSupportingFiles supporting_files_{SkillSupportingFiles::Template}; + mutable bool discovered_{false}; + mutable std::vector> providers_; +}; + +class ClaudeSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit ClaudeSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class CursorSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit CursorSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class VSCodeSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit VSCodeSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class CodexSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit CodexSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class GeminiSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit GeminiSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class GooseSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit GooseSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class CopilotSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit CopilotSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +class OpenCodeSkillsProvider : public SkillsDirectoryProvider +{ + public: + explicit OpenCodeSkillsProvider(bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); +}; + +using SkillsProvider = SkillsDirectoryProvider; + +} // namespace fastmcpp::providers diff --git a/include/fastmcpp/providers/transforms/prompts_as_tools.hpp b/include/fastmcpp/providers/transforms/prompts_as_tools.hpp new file mode 100644 index 0000000..97a084f --- /dev/null +++ b/include/fastmcpp/providers/transforms/prompts_as_tools.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "fastmcpp/providers/transforms/transform.hpp" + +namespace fastmcpp::providers +{ +class Provider; +} + +namespace fastmcpp::providers::transforms +{ + +/// Transform that injects list_prompts and get_prompt as synthetic tools. +/// +/// Parity with Python fastmcp PromptsAsTools transform. +class PromptsAsTools : public Transform +{ + public: + PromptsAsTools() = default; + + std::vector list_tools(const ListToolsNext& call_next) const override; + std::optional get_tool(const std::string& name, + const GetToolNext& call_next) const override; + + void set_provider(const Provider* provider) + { + provider_ = provider; + } + + private: + const Provider* provider_{nullptr}; + + tools::Tool make_list_prompts_tool() const; + tools::Tool make_get_prompt_tool() const; +}; + +} // namespace fastmcpp::providers::transforms diff --git a/include/fastmcpp/providers/transforms/resources_as_tools.hpp b/include/fastmcpp/providers/transforms/resources_as_tools.hpp new file mode 100644 index 0000000..cd288ed --- /dev/null +++ b/include/fastmcpp/providers/transforms/resources_as_tools.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "fastmcpp/providers/transforms/transform.hpp" +#include "fastmcpp/resources/resource.hpp" + +#include + +namespace fastmcpp::providers +{ +class Provider; +} + +namespace fastmcpp::providers::transforms +{ + +/// Transform that injects list_resources and read_resource as synthetic tools. +/// +/// Parity with Python fastmcp ResourcesAsTools transform. +class ResourcesAsTools : public Transform +{ + public: + ResourcesAsTools() = default; + + std::vector list_tools(const ListToolsNext& call_next) const override; + std::optional get_tool(const std::string& name, + const GetToolNext& call_next) const override; + + void set_provider(const Provider* provider) + { + provider_ = provider; + } + + using ResourceReader = + std::function; + void set_resource_reader(ResourceReader reader) + { + resource_reader_ = std::move(reader); + } + + private: + const Provider* provider_{nullptr}; + ResourceReader resource_reader_; + + tools::Tool make_list_resources_tool() const; + tools::Tool make_read_resource_tool() const; +}; + +} // namespace fastmcpp::providers::transforms diff --git a/include/fastmcpp/providers/transforms/version_filter.hpp b/include/fastmcpp/providers/transforms/version_filter.hpp new file mode 100644 index 0000000..93aaba2 --- /dev/null +++ b/include/fastmcpp/providers/transforms/version_filter.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/providers/transforms/transform.hpp" + +#include +#include + +namespace fastmcpp::providers::transforms +{ + +class VersionFilter : public Transform +{ + public: + VersionFilter(std::optional version_gte, std::optional version_lt); + explicit VersionFilter(std::string version_gte); + + std::vector list_tools(const ListToolsNext& call_next) const override; + std::optional get_tool(const std::string& name, + const GetToolNext& call_next) const override; + + std::vector list_resources(const ListResourcesNext& call_next) const override; + std::optional + get_resource(const std::string& uri, const GetResourceNext& call_next) const override; + + std::vector + list_resource_templates(const ListResourceTemplatesNext& call_next) const override; + std::optional + get_resource_template(const std::string& uri, + const GetResourceTemplateNext& call_next) const override; + + std::vector list_prompts(const ListPromptsNext& call_next) const override; + std::optional get_prompt(const std::string& name, + const GetPromptNext& call_next) const override; + + private: + bool matches(const std::optional& version) const; + + std::optional version_gte_; + std::optional version_lt_; +}; + +} // namespace fastmcpp::providers::transforms diff --git a/include/fastmcpp/proxy.hpp b/include/fastmcpp/proxy.hpp index e66e01f..6fe41eb 100644 --- a/include/fastmcpp/proxy.hpp +++ b/include/fastmcpp/proxy.hpp @@ -160,7 +160,7 @@ class ProxyApp /// /// The target can be: /// - A client::Client instance -/// - A URL string (HTTP/SSE/WebSocket) +/// - A URL string (HTTP/SSE) /// /// Note: To proxy to another FastMCP server instance, use FastMCP::mount() instead. /// For transports, create a Client first then pass it to create_proxy(). diff --git a/include/fastmcpp/resources/resource.hpp b/include/fastmcpp/resources/resource.hpp index 44bb1d2..52f499d 100644 --- a/include/fastmcpp/resources/resource.hpp +++ b/include/fastmcpp/resources/resource.hpp @@ -23,11 +23,13 @@ struct Resource { std::string uri; // e.g., "file://readme.txt" std::string name; // Human-readable name + std::optional version; // Optional component version std::optional description; // Optional description std::optional mime_type; // MIME type hint std::optional title; // Human-readable display title std::optional annotations; // {audience, priority, lastModified} std::optional> icons; // Icons for UI display + std::optional app; // MCP Apps metadata (_meta.ui) std::function provider; // Content provider function fastmcpp::TaskSupport task_support{fastmcpp::TaskSupport::Forbidden}; // SEP-1686 task mode diff --git a/include/fastmcpp/resources/template.hpp b/include/fastmcpp/resources/template.hpp index 72d9352..c1e515a 100644 --- a/include/fastmcpp/resources/template.hpp +++ b/include/fastmcpp/resources/template.hpp @@ -29,11 +29,13 @@ struct ResourceTemplate { std::string uri_template; // e.g., "weather://{city}/current" std::string name; // Human-readable name + std::optional version; // Optional component version std::optional description; // Optional description std::optional mime_type; // MIME type hint std::optional title; // Human-readable display title std::optional annotations; // {audience, priority, lastModified} std::optional> icons; // Icons for UI display + std::optional app; // MCP Apps metadata (_meta.ui) Json parameters; // JSON schema for parameters fastmcpp::TaskSupport task_support{fastmcpp::TaskSupport::Forbidden}; // SEP-1686 task mode diff --git a/include/fastmcpp/server/context.hpp b/include/fastmcpp/server/context.hpp index 585a17b..9458964 100644 --- a/include/fastmcpp/server/context.hpp +++ b/include/fastmcpp/server/context.hpp @@ -7,6 +7,8 @@ #include #include +#include +#include #include #include #include @@ -168,6 +170,9 @@ inline std::string to_string(TransportType transport) } } +using SessionState = std::unordered_map; +using SessionStatePtr = std::shared_ptr; + using LogCallback = std::function; using ProgressCallback = std::function; @@ -181,7 +186,8 @@ class Context std::optional request_meta, std::optional request_id = std::nullopt, std::optional session_id = std::nullopt, - std::optional transport = std::nullopt); + std::optional transport = std::nullopt, + SessionStatePtr session_state = nullptr); std::vector list_resources() const; std::vector list_prompts() const; @@ -211,6 +217,19 @@ class Context return transport_; } + /// Check whether the connected client advertised a capability extension. + /// Expects extensions under request meta key "client_extensions". + bool client_supports_extension(const std::string& extension_id) const + { + if (!request_meta_ || !request_meta_->is_object() || extension_id.empty()) + return false; + if (!request_meta_->contains("client_extensions") || + !(*request_meta_)["client_extensions"].is_object()) + return false; + const auto& extensions = (*request_meta_)["client_extensions"]; + return extensions.contains(extension_id); + } + std::optional client_id() const { if (request_meta_.has_value() && request_meta_->contains("client_id")) @@ -275,6 +294,48 @@ class Context return keys; } + // Session-scoped state (shared across all contexts in the same session) + template + void set_session_state(const std::string& key, T&& value) + { + if (!session_state_) + throw std::runtime_error("Session state not available"); + (*session_state_)[key] = std::forward(value); + } + + std::any get_session_state(const std::string& key) const + { + if (!session_state_) + return {}; + auto it = session_state_->find(key); + return it != session_state_->end() ? it->second : std::any{}; + } + + bool has_session_state(const std::string& key) const + { + return session_state_ && session_state_->count(key) > 0; + } + + template + T get_session_state_or(const std::string& key, T default_value) const + { + if (!session_state_) + return default_value; + auto it = session_state_->find(key); + if (it != session_state_->end()) + { + try + { + return std::any_cast(it->second); + } + catch (const std::bad_any_cast&) + { + return default_value; + } + } + return default_value; + } + void set_log_callback(LogCallback callback) { log_callback_ = std::move(callback); @@ -433,6 +494,7 @@ class Context std::optional session_id_; std::optional transport_; mutable std::unordered_map state_; + SessionStatePtr session_state_; LogCallback log_callback_; ProgressCallback progress_callback_; NotificationCallback notification_callback_; diff --git a/include/fastmcpp/server/ping_middleware.hpp b/include/fastmcpp/server/ping_middleware.hpp new file mode 100644 index 0000000..a10d54f --- /dev/null +++ b/include/fastmcpp/server/ping_middleware.hpp @@ -0,0 +1,33 @@ +#pragma once +#include "fastmcpp/server/middleware.hpp" +#include "fastmcpp/types.hpp" + +#include +#include + +namespace fastmcpp::server +{ + +/// Ping middleware that periodically sends pings during long-running tool calls. +/// +/// Parity with Python fastmcp PingMiddleware. +/// Note: simplified implementation — stores interval for future integration with +/// session-based ping sending. +class PingMiddleware +{ + public: + explicit PingMiddleware(std::chrono::milliseconds interval = std::chrono::seconds(15)); + + /// Returns a pair of BeforeHook (starts timer) and AfterHook (stops timer). + std::pair make_hooks() const; + + std::chrono::milliseconds interval() const + { + return interval_; + } + + private: + std::chrono::milliseconds interval_; +}; + +} // namespace fastmcpp::server diff --git a/include/fastmcpp/server/response_limiting_middleware.hpp b/include/fastmcpp/server/response_limiting_middleware.hpp new file mode 100644 index 0000000..a6ce75a --- /dev/null +++ b/include/fastmcpp/server/response_limiting_middleware.hpp @@ -0,0 +1,30 @@ +#pragma once +#include "fastmcpp/server/middleware.hpp" +#include "fastmcpp/types.hpp" + +#include +#include + +namespace fastmcpp::server +{ + +/// Response limiting middleware that truncates oversized tool call responses. +/// +/// Parity with Python fastmcp ResponseLimiting middleware. +class ResponseLimitingMiddleware +{ + public: + explicit ResponseLimitingMiddleware(size_t max_size = 1'000'000, + std::string truncation_suffix = "... [truncated]", + std::vector tool_filter = {}); + + /// Returns an AfterHook that truncates tools/call responses + AfterHook make_hook() const; + + private: + size_t max_size_; + std::string truncation_suffix_; + std::vector tool_filter_; +}; + +} // namespace fastmcpp::server diff --git a/include/fastmcpp/server/session.hpp b/include/fastmcpp/server/session.hpp index 8061100..d18d40f 100644 --- a/include/fastmcpp/server/session.hpp +++ b/include/fastmcpp/server/session.hpp @@ -107,6 +107,7 @@ class ServerSession supports_sampling_tools_ = false; supports_elicitation_ = false; supports_roots_ = false; + supported_extensions_.clear(); if (capabilities.contains("sampling") && capabilities["sampling"].is_object()) { supports_sampling_ = true; @@ -118,6 +119,9 @@ class ServerSession supports_elicitation_ = true; if (capabilities.contains("roots") && capabilities["roots"].is_object()) supports_roots_ = true; + if (capabilities.contains("extensions") && capabilities["extensions"].is_object()) + for (const auto& [extension_id, _] : capabilities["extensions"].items()) + supported_extensions_.insert(extension_id); } /// Check if client supports sampling @@ -148,6 +152,13 @@ class ServerSession return supports_roots_; } + /// Check if client supports an extension declared under capabilities.extensions + bool supports_extension(const std::string& extension_id) const + { + std::lock_guard lock(cap_mutex_); + return supported_extensions_.find(extension_id) != supported_extensions_.end(); + } + /// Get raw capabilities JSON Json capabilities() const { @@ -364,6 +375,7 @@ class ServerSession bool supports_elicitation_{false}; bool supports_roots_{false}; bool supports_sampling_tools_{false}; + std::unordered_set supported_extensions_; // Pending requests std::mutex pending_mutex_; diff --git a/include/fastmcpp/tools/manager.hpp b/include/fastmcpp/tools/manager.hpp index a24a53b..061c00b 100644 --- a/include/fastmcpp/tools/manager.hpp +++ b/include/fastmcpp/tools/manager.hpp @@ -17,7 +17,10 @@ class ToolManager } const Tool& get(const std::string& name) const { - return tools_.at(name); + auto it = tools_.find(name); + if (it == tools_.end()) + throw fastmcpp::NotFoundError("tool not found: " + name); + return it->second; } bool has(const std::string& name) const { diff --git a/include/fastmcpp/tools/tool.hpp b/include/fastmcpp/tools/tool.hpp index c23fb6b..1f0c36d 100644 --- a/include/fastmcpp/tools/tool.hpp +++ b/include/fastmcpp/tools/tool.hpp @@ -26,10 +26,13 @@ class Tool // Original constructor (backward compatible) Tool(std::string name, fastmcpp::Json input_schema, fastmcpp::Json output_schema, Fn fn, std::vector exclude_args = {}, - fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden) + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden, + std::optional app = std::nullopt, + std::optional version = std::nullopt) : name_(std::move(name)), input_schema_(std::move(input_schema)), output_schema_(std::move(output_schema)), fn_(std::move(fn)), - exclude_args_(std::move(exclude_args)), task_support_(task_support) + exclude_args_(std::move(exclude_args)), task_support_(task_support), app_(std::move(app)), + version_(std::move(version)) { } @@ -38,11 +41,13 @@ class Tool std::optional title, std::optional description, std::optional> icons, std::vector exclude_args = {}, - fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden) + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden, + std::optional app = std::nullopt, + std::optional version = std::nullopt) : name_(std::move(name)), title_(std::move(title)), description_(std::move(description)), input_schema_(std::move(input_schema)), output_schema_(std::move(output_schema)), icons_(std::move(icons)), fn_(std::move(fn)), exclude_args_(std::move(exclude_args)), - task_support_(task_support) + task_support_(task_support), app_(std::move(app)), version_(std::move(version)) { } @@ -117,6 +122,14 @@ class Tool { return task_support_; } + const std::optional& app() const + { + return app_; + } + const std::optional& version() const + { + return version_; + } // Setters for optional fields (builder pattern) Tool& set_title(std::string title) @@ -139,6 +152,16 @@ class Tool task_support_ = support; return *this; } + Tool& set_app(fastmcpp::AppConfig app) + { + app_ = std::move(app); + return *this; + } + Tool& set_version(std::string version) + { + version_ = std::move(version); + return *this; + } Tool& set_timeout(std::optional timeout) { timeout_ = timeout; @@ -148,6 +171,24 @@ class Tool { return timeout_; } + bool is_hidden() const + { + return hidden_; + } + Tool& set_hidden(bool hidden) + { + hidden_ = hidden; + return *this; + } + bool sequential() const + { + return sequential_; + } + Tool& set_sequential(bool seq) + { + sequential_ = seq; + return *this; + } private: static std::string format_timeout_seconds(std::chrono::milliseconds timeout) @@ -207,6 +248,10 @@ class Tool std::vector exclude_args_; fastmcpp::TaskSupport task_support_{fastmcpp::TaskSupport::Forbidden}; std::optional timeout_; + bool hidden_{false}; + bool sequential_{false}; + std::optional app_; + std::optional version_; }; } // namespace fastmcpp::tools diff --git a/include/fastmcpp/tools/tool_transform.hpp b/include/fastmcpp/tools/tool_transform.hpp index 04a15a8..fe5ba7c 100644 --- a/include/fastmcpp/tools/tool_transform.hpp +++ b/include/fastmcpp/tools/tool_transform.hpp @@ -231,11 +231,15 @@ struct ToolTransformConfig std::optional name; std::optional description; std::unordered_map arguments; + std::optional enabled; // When false, tool is hidden from listings /// Apply this configuration to create a transformed tool Tool apply(const Tool& tool) const { - return create_transformed_tool(tool, name, description, arguments); + auto result = create_transformed_tool(tool, name, description, arguments); + if (enabled.has_value() && !*enabled) + result.set_hidden(true); + return result; } }; diff --git a/include/fastmcpp/types.hpp b/include/fastmcpp/types.hpp index 0bef593..2f32938 100644 --- a/include/fastmcpp/types.hpp +++ b/include/fastmcpp/types.hpp @@ -56,6 +56,25 @@ struct Icon sizes; ///< Optional dimensions (e.g., ["48x48", "96x96"]) }; +/// MCP Apps configuration metadata (FastMCP 3.x parity subset). +/// This is serialized under `_meta.ui`. +struct AppConfig +{ + std::optional resource_uri; + std::optional> visibility; + std::optional csp; + std::optional permissions; + std::optional domain; + std::optional prefers_border; + Json extra = Json::object(); // Forward-compatible unknown fields + + bool empty() const + { + return !resource_uri && !visibility && !csp && !permissions && !domain && + !prefers_border && (extra.is_null() || extra.empty()); + } +}; + // nlohmann::json adapters inline void to_json(Json& j, const Id& id) { @@ -84,4 +103,56 @@ inline void from_json(const Json& j, Icon& icon) icon.sizes = j["sizes"].get>(); } +inline void to_json(Json& j, const AppConfig& app) +{ + j = Json::object(); + if (app.resource_uri) + j["resourceUri"] = *app.resource_uri; + if (app.visibility) + j["visibility"] = *app.visibility; + if (app.csp) + j["csp"] = *app.csp; + if (app.permissions) + j["permissions"] = *app.permissions; + if (app.domain) + j["domain"] = *app.domain; + if (app.prefers_border.has_value()) + j["prefersBorder"] = *app.prefers_border; + + if (app.extra.is_object()) + for (const auto& [k, v] : app.extra.items()) + if (!j.contains(k)) + j[k] = v; +} + +inline void from_json(const Json& j, AppConfig& app) +{ + if (j.contains("resourceUri")) + app.resource_uri = j["resourceUri"].get(); + else if (j.contains("resource_uri")) + app.resource_uri = j["resource_uri"].get(); + if (j.contains("visibility")) + app.visibility = j["visibility"].get>(); + if (j.contains("csp")) + app.csp = j["csp"]; + if (j.contains("permissions")) + app.permissions = j["permissions"]; + if (j.contains("domain")) + app.domain = j["domain"].get(); + if (j.contains("prefersBorder")) + app.prefers_border = j["prefersBorder"].get(); + else if (j.contains("prefers_border")) + app.prefers_border = j["prefers_border"].get(); + + app.extra = Json::object(); + for (const auto& [k, v] : j.items()) + { + if (k == "resource_uri" || k == "visibility" || k == "csp" || k == "permissions" || + k == "domain" || k == "prefers_border" || k == "resourceUri" || + k == "prefersBorder") + continue; + app.extra[k] = v; + } +} + } // namespace fastmcpp diff --git a/include/fastmcpp/util/json_schema.hpp b/include/fastmcpp/util/json_schema.hpp index 60a64a7..8d9a802 100644 --- a/include/fastmcpp/util/json_schema.hpp +++ b/include/fastmcpp/util/json_schema.hpp @@ -15,5 +15,7 @@ namespace fastmcpp::util::schema // - properties: { name: { type: ... } } void validate(const Json& schema, const Json& instance); +bool contains_ref(const Json& schema); +Json dereference_refs(const Json& schema); } // namespace fastmcpp::util::schema diff --git a/include/fastmcpp/util/pagination.hpp b/include/fastmcpp/util/pagination.hpp new file mode 100644 index 0000000..7aab8c9 --- /dev/null +++ b/include/fastmcpp/util/pagination.hpp @@ -0,0 +1,142 @@ +#pragma once +#include "fastmcpp/types.hpp" + +#include +#include +#include + +namespace fastmcpp::util::pagination +{ + +/// Decoded cursor state +struct CursorState +{ + int offset{0}; +}; + +/// Base64 encode a string +inline std::string base64_encode(const std::string& input) +{ + static const char* b64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string result; + result.reserve((input.size() + 2) / 3 * 4); + for (size_t i = 0; i < input.size(); i += 3) + { + uint32_t n = static_cast(input[i]) << 16; + if (i + 1 < input.size()) + n |= static_cast(input[i + 1]) << 8; + if (i + 2 < input.size()) + n |= static_cast(input[i + 2]); + result.push_back(b64_chars[(n >> 18) & 0x3F]); + result.push_back(b64_chars[(n >> 12) & 0x3F]); + result.push_back((i + 1 < input.size()) ? b64_chars[(n >> 6) & 0x3F] : '='); + result.push_back((i + 2 < input.size()) ? b64_chars[n & 0x3F] : '='); + } + return result; +} + +/// Base64 decode a string; returns empty string on invalid input +inline std::string base64_decode(const std::string& input) +{ + static const int b64_table[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, + -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51}; + + std::string result; + result.reserve(input.size() * 3 / 4); + for (size_t i = 0; i < input.size(); i += 4) + { + if (i + 3 >= input.size()) + break; + auto val = [&](size_t idx) -> int + { + auto c = static_cast(input[idx]); + if (c == '=') + return 0; + if (c >= sizeof(b64_table) / sizeof(b64_table[0])) + return -1; + return b64_table[c]; + }; + int a = val(i), b = val(i + 1), c = val(i + 2), d = val(i + 3); + if (a < 0 || b < 0 || c < 0 || d < 0) + return {}; + uint32_t n = (a << 18) | (b << 12) | (c << 6) | d; + result.push_back(static_cast((n >> 16) & 0xFF)); + if (input[i + 2] != '=') + result.push_back(static_cast((n >> 8) & 0xFF)); + if (input[i + 3] != '=') + result.push_back(static_cast(n & 0xFF)); + } + return result; +} + +/// Encode an offset into a cursor string +inline std::string encode_cursor(int offset) +{ + Json j = {{"o", offset}}; + return base64_encode(j.dump()); +} + +/// Decode a cursor string into a CursorState; returns {0} on error +inline CursorState decode_cursor(const std::string& cursor) +{ + try + { + auto decoded = base64_decode(cursor); + if (decoded.empty()) + return {0}; + auto j = Json::parse(decoded); + return {j.value("o", 0)}; + } + catch (...) + { + return {0}; + } +} + +/// Paginated result with items and optional next cursor +template +struct PaginatedResult +{ + std::vector items; + std::optional next_cursor; +}; + +/// Paginate a sequence by cursor offset +template +PaginatedResult paginate_sequence(const std::vector& items, + const std::optional& cursor, int page_size) +{ + if (page_size <= 0) + return {items, std::nullopt}; + + int offset = 0; + if (cursor.has_value() && !cursor->empty()) + offset = decode_cursor(*cursor).offset; + + if (offset < 0) + offset = 0; + + auto begin = items.begin(); + if (static_cast(offset) >= items.size()) + return {{}, std::nullopt}; + + std::advance(begin, offset); + auto end = begin; + auto remaining = static_cast(std::distance(begin, items.end())); + std::advance(end, std::min(page_size, remaining)); + + std::vector page(begin, end); + std::optional next; + if (static_cast(offset + page_size) < items.size()) + next = encode_cursor(offset + page_size); + + return {std::move(page), std::move(next)}; +} + +} // namespace fastmcpp::util::pagination diff --git a/src/app.cpp b/src/app.cpp index a0225b0..493eb0d 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6,6 +6,7 @@ #include "fastmcpp/mcp/handler.hpp" #include "fastmcpp/providers/provider.hpp" #include "fastmcpp/resources/template.hpp" +#include "fastmcpp/util/json_schema.hpp" #include "fastmcpp/util/schema_build.hpp" #include @@ -16,10 +17,14 @@ namespace fastmcpp FastMCP::FastMCP(std::string name, std::string version, std::optional website_url, std::optional> icons, - std::vector> providers) + std::vector> providers, int list_page_size, + bool dereference_schemas) : server_(std::move(name), std::move(version), std::move(website_url), std::move(icons)), - providers_(std::move(providers)) + providers_(std::move(providers)), list_page_size_(list_page_size), + dereference_schemas_(dereference_schemas) { + if (list_page_size < 0) + throw ValidationError("list_page_size must be >= 0"); for (const auto& provider : providers_) if (!provider) throw ValidationError("provider cannot be null"); @@ -54,6 +59,33 @@ fastmcpp::Json build_resource_template_parameters_schema(const std::string& uri_ {"required", required}, }; } + +bool has_ui_scheme(const std::string& uri) +{ + return uri.rfind("ui://", 0) == 0; +} + +std::optional normalize_ui_mime(const std::string& uri, + const std::optional& mime_type) +{ + if (mime_type) + return mime_type; + if (has_ui_scheme(uri)) + return std::string("text/html;profile=mcp-app"); + return mime_type; +} + +void validate_resource_app_config(const std::optional& app) +{ + if (!app) + return; + if (app->resource_uri) + throw fastmcpp::ValidationError( + "AppConfig.resource_uri is not applicable for resources/resource templates"); + if (app->visibility) + throw fastmcpp::ValidationError( + "AppConfig.visibility is not applicable for resources/resource templates"); +} } // namespace FastMCP& FastMCP::tool(std::string name, const Json& input_schema_or_simple, tools::Tool::Fn fn, @@ -69,9 +101,13 @@ FastMCP& FastMCP::tool(std::string name, const Json& input_schema_or_simple, too std::move(options.description), std::move(options.icons), std::move(options.exclude_args), - options.task_support}; + options.task_support, + std::move(options.app), + std::move(options.version)}; if (options.timeout) t.set_timeout(*options.timeout); + if (options.sequential) + t.set_sequential(true); tools_.register_tool(t); return *this; @@ -88,6 +124,7 @@ FastMCP& FastMCP::prompt(std::string name, { prompts::Prompt p; p.name = std::move(name); + p.version = std::move(options.version); p.description = std::move(options.description); p.meta = std::move(options.meta); p.arguments = std::move(options.arguments); @@ -102,6 +139,7 @@ FastMCP& FastMCP::prompt_template(std::string name, std::string template_string, { prompts::Prompt p{std::move(template_string)}; p.name = std::move(name); + p.version = std::move(options.version); p.description = std::move(options.description); p.meta = std::move(options.meta); p.arguments = std::move(options.arguments); @@ -117,11 +155,14 @@ FastMCP& FastMCP::resource(std::string uri, std::string name, resources::Resource r; r.uri = std::move(uri); r.name = std::move(name); + r.version = std::move(options.version); r.description = std::move(options.description); - r.mime_type = std::move(options.mime_type); + r.mime_type = normalize_ui_mime(r.uri, options.mime_type); r.title = std::move(options.title); r.annotations = std::move(options.annotations); r.icons = std::move(options.icons); + validate_resource_app_config(options.app); + r.app = std::move(options.app); r.provider = std::move(provider); r.task_support = options.task_support; resources_.register_resource(r); @@ -136,11 +177,14 @@ FastMCP::resource_template(std::string uri_template, std::string name, resources::ResourceTemplate templ; templ.uri_template = std::move(uri_template); templ.name = std::move(name); + templ.version = std::move(options.version); templ.description = std::move(options.description); - templ.mime_type = std::move(options.mime_type); + templ.mime_type = normalize_ui_mime(templ.uri_template, options.mime_type); templ.title = std::move(options.title); templ.annotations = std::move(options.annotations); templ.icons = std::move(options.icons); + validate_resource_app_config(options.app); + templ.app = std::move(options.app); templ.task_support = options.task_support; templ.provider = std::move(provider); @@ -386,6 +430,20 @@ std::vector FastMCP::list_all_tools_info() const { std::vector result; std::unordered_set seen; + auto maybe_dereference_schema = [this](const Json& schema) -> Json + { + if (!dereference_schemas_) + return schema; + if (!util::schema::contains_ref(schema)) + return schema; + return util::schema::dereference_refs(schema); + }; + auto normalize_tool_info_schemas = [&](client::ToolInfo& info) + { + info.inputSchema = maybe_dereference_schema(info.inputSchema); + if (info.outputSchema && !info.outputSchema->is_null()) + *info.outputSchema = maybe_dereference_schema(*info.outputSchema); + }; auto append_tool_info = [&](const tools::Tool& tool, const std::string& name) { @@ -393,15 +451,28 @@ std::vector FastMCP::list_all_tools_info() const return; client::ToolInfo info; info.name = name; - info.inputSchema = tool.input_schema(); + info.inputSchema = maybe_dereference_schema(tool.input_schema()); info.title = tool.title(); info.description = tool.description(); auto out_schema = tool.output_schema(); if (!out_schema.is_null()) - info.outputSchema = out_schema; - if (tool.task_support() != TaskSupport::Forbidden) - info.execution = Json{{"taskSupport", to_string(tool.task_support())}}; + info.outputSchema = maybe_dereference_schema(out_schema); + if (tool.task_support() != TaskSupport::Forbidden || tool.sequential()) + { + Json execution = Json::object(); + if (tool.task_support() != TaskSupport::Forbidden) + execution["taskSupport"] = to_string(tool.task_support()); + if (tool.sequential()) + execution["concurrency"] = "sequential"; + info.execution = execution; + } info.icons = tool.icons(); + if (tool.app() && !tool.app()->empty()) + { + info.app = *tool.app(); + info._meta = Json{{"ui", *tool.app()}}; + } + normalize_tool_info_schemas(info); result.push_back(info); }; @@ -437,6 +508,7 @@ std::vector FastMCP::list_all_tools_info() const { tool_info.name = add_prefix(tool_info.name, mounted.prefix); } + normalize_tool_info_schemas(tool_info); if (seen.insert(tool_info.name).second) result.push_back(tool_info); } @@ -462,6 +534,7 @@ std::vector FastMCP::list_all_tools_info() const { tool_info.name = add_prefix(tool_info.name, proxy_mount.prefix); } + normalize_tool_info_schemas(tool_info); if (seen.insert(tool_info.name).second) result.push_back(tool_info); } @@ -521,6 +594,11 @@ std::vector FastMCP::list_all_resources() const res.description = *res_info.description; if (res_info.mimeType) res.mime_type = *res_info.mimeType; + if (res_info.app && !res_info.app->empty()) + res.app = *res_info.app; + else if (res_info._meta && res_info._meta->contains("ui") && + (*res_info._meta)["ui"].is_object()) + res.app = (*res_info._meta)["ui"].get(); // Note: provider is not set - reading goes through invoke_tool routing add_resource(res); } @@ -533,11 +611,22 @@ std::vector FastMCP::list_all_templates() const { std::vector result; std::unordered_set seen; + auto maybe_dereference_schema = [this](const Json& schema) -> Json + { + if (!dereference_schemas_) + return schema; + if (!util::schema::contains_ref(schema)) + return schema; + return util::schema::dereference_refs(schema); + }; auto add_template = [&](const resources::ResourceTemplate& templ) { - if (seen.insert(templ.uri_template).second) - result.push_back(templ); + resources::ResourceTemplate normalized = templ; + if (!normalized.parameters.is_null()) + normalized.parameters = maybe_dereference_schema(normalized.parameters); + if (seen.insert(normalized.uri_template).second) + result.push_back(std::move(normalized)); }; // Add local templates first @@ -580,6 +669,19 @@ std::vector FastMCP::list_all_templates() const templ.description = *templ_info.description; if (templ_info.mimeType) templ.mime_type = *templ_info.mimeType; + if (templ_info.title) + templ.title = *templ_info.title; + if (templ_info.parameters) + templ.parameters = *templ_info.parameters; + if (templ_info.annotations) + templ.annotations = *templ_info.annotations; + if (templ_info.icons) + templ.icons = *templ_info.icons; + if (templ_info.app && !templ_info.app->empty()) + templ.app = *templ_info.app; + else if (templ_info._meta && templ_info._meta->contains("ui") && + (*templ_info._meta)["ui"].is_object()) + templ.app = (*templ_info._meta)["ui"].get(); add_template(templ); } } diff --git a/src/cli/main.cpp b/src/cli/main.cpp index f9ace13..1be66a2 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -6,19 +6,59 @@ #include "fastmcpp/version.hpp" #include +#include +#include +#include #include +#include +#include #include #include #include +#include +#include #include #include #include +#include +#include #include #include namespace { +struct Connection +{ + enum class Kind + { + Http, + StreamableHttp, + Stdio, + }; + + Kind kind = Kind::Http; + std::string url_or_command; + std::string mcp_path = "/mcp"; + std::vector stdio_args; + bool stdio_keep_alive = true; + std::vector> headers; +}; + +static void print_connection_options() +{ + 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 << " --stdio Spawn an MCP stdio server\n"; + std::cout << " --stdio-arg Repeatable args for --stdio\n"; + std::cout << " --stdio-one-shot Spawn a fresh process per request (disables keep-alive)\n"; + std::cout << " --header Repeatable header for HTTP/streamable-http\n"; +} + static int usage(int exit_code = 1) { std::cout << "fastmcpp " << fastmcpp::VERSION_MAJOR << "." << fastmcpp::VERSION_MINOR << "." @@ -26,7 +66,14 @@ static int usage(int exit_code = 1) std::cout << "Usage:\n"; std::cout << " fastmcpp --help\n"; std::cout << " fastmcpp client sum \n"; + std::cout << " fastmcpp discover [connection options] [--pretty]\n"; + std::cout << " fastmcpp list [connection options] [--pretty]\n"; + std::cout << " fastmcpp call [--args ] [connection options] [--pretty]\n"; + std::cout << " fastmcpp generate-cli [output] [--force] [--timeout ] [--auth ] [--header ] [--no-skill]\n"; + std::cout << " fastmcpp install [server_spec]\n"; std::cout << " fastmcpp tasks --help\n"; + std::cout << "\n"; + print_connection_options(); return exit_code; } @@ -36,49 +83,44 @@ static int tasks_usage(int exit_code = 1) 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 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 << " 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 << " --stdio-one-shot Spawn a fresh process per request (disables " - "keep-alive)\n"; + print_connection_options(); std::cout << "\n"; std::cout << "Notes:\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 << " - 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 +static int install_usage(int exit_code = 1) { - enum class Kind - { - Http, - StreamableHttp, - WebSocket, - Stdio, - }; + std::cout << "fastmcpp install\n"; + std::cout << "Usage:\n"; + std::cout << " fastmcpp install [--name ] [--command ] [--arg ] [--with ] [--with-editable ] [--python ] [--with-requirements ] [--project ] [--env KEY=VALUE] [--env-file ] [--workspace ] [--copy]\n"; + std::cout << "Targets:\n"; + std::cout << " stdio Print stdio launch command\n"; + std::cout << " mcp-json Print MCP JSON entry (\"name\": {command,args,env})\n"; + std::cout << " goose Print goose install command\n"; + std::cout << " cursor Print Cursor deeplink URL\n"; + std::cout << " claude-desktop Print config snippet for Claude Desktop\n"; + std::cout << " claude-code Print claude-code install command\n"; + std::cout << " gemini-cli Print gemini-cli install command\n"; + return exit_code; +} - Kind kind = Kind::Http; - std::string url_or_command; - std::string mcp_path = "/mcp"; - std::vector stdio_args; - bool stdio_keep_alive = true; -}; +static std::vector collect_args(int argc, char** argv, int start) +{ + std::vector args; + args.reserve(static_cast(argc)); + for (int i = start; i < argc; ++i) + args.emplace_back(argv[i]); + return args; +} static bool is_flag(const std::string& s) { @@ -114,6 +156,20 @@ static bool consume_flag(std::vector& args, const std::string& flag return false; } +static std::vector consume_all_flag_values(std::vector& args, + const std::string& flag) +{ + std::vector values; + while (true) + { + auto value = consume_flag_value(args, flag); + if (!value) + break; + values.push_back(*value); + } + return values; +} + static int parse_int(const std::string& s, int default_value) { try @@ -130,34 +186,28 @@ static int parse_int(const std::string& s, int default_value) } } -static std::optional parse_tasks_connection(std::vector& args) +static std::optional parse_connection(std::vector& args) { - TasksConnection conn; + Connection conn; bool saw_any = false; if (auto http = consume_flag_value(args, "--http")) { - conn.kind = TasksConnection::Kind::Http; + conn.kind = Connection::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.kind = Connection::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.kind = Connection::Kind::Stdio; conn.url_or_command = *stdio; saw_any = true; } @@ -172,30 +222,110 @@ static std::optional parse_tasks_connection(std::vectorfind('='); + if (pos == std::string::npos || pos == 0) + continue; + conn.headers.emplace_back(hdr->substr(0, pos), hdr->substr(pos + 1)); + } + if (!saw_any) return std::nullopt; return conn; } -static fastmcpp::client::Client make_client_from_connection(const TasksConnection& conn) +static std::vector connection_to_cli_args(const Connection& conn) +{ + std::vector out; + switch (conn.kind) + { + case Connection::Kind::Http: + out = {"--http", conn.url_or_command}; + break; + case Connection::Kind::StreamableHttp: + out = {"--streamable-http", conn.url_or_command}; + if (conn.mcp_path != "/mcp") + { + out.push_back("--mcp-path"); + out.push_back(conn.mcp_path); + } + break; + case Connection::Kind::Stdio: + out = {"--stdio", conn.url_or_command}; + for (const auto& arg : conn.stdio_args) + { + out.push_back("--stdio-arg"); + out.push_back(arg); + } + if (!conn.stdio_keep_alive) + out.push_back("--stdio-one-shot"); + break; + } + for (const auto& [key, value] : conn.headers) + { + out.push_back("--header"); + out.push_back(key + "=" + value); + } + return out; +} + +static fastmcpp::client::Client make_client_from_connection(const Connection& conn) { + std::unordered_map headers; + for (const auto& [key, value] : conn.headers) + headers[key] = value; + 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: + case Connection::Kind::Http: + return Client(std::make_unique(conn.url_or_command, + std::chrono::seconds(300), headers)); + case Connection::Kind::StreamableHttp: + return Client(std::make_unique(conn.url_or_command, conn.mcp_path, + headers)); + case Connection::Kind::Stdio: return Client(std::make_unique(conn.url_or_command, conn.stdio_args, std::nullopt, conn.stdio_keep_alive)); } throw std::runtime_error("Unsupported transport kind"); } +static fastmcpp::Json default_initialize_params() +{ + return fastmcpp::Json{ + {"protocolVersion", "2024-11-05"}, + {"capabilities", fastmcpp::Json::object()}, + {"clientInfo", + fastmcpp::Json{{"name", "fastmcpp-cli"}, + {"version", std::to_string(fastmcpp::VERSION_MAJOR) + "." + + std::to_string(fastmcpp::VERSION_MINOR) + "." + + std::to_string(fastmcpp::VERSION_PATCH)}}}, + }; +} + +static fastmcpp::Json initialize_client(fastmcpp::client::Client& client) +{ + return client.call("initialize", default_initialize_params()); +} + +static std::string reject_unknown_flags(const std::vector& rest) +{ + for (const auto& a : rest) + if (is_flag(a)) + return a; + return std::string(); +} + +static void dump_json(const fastmcpp::Json& j, bool pretty) +{ + std::cout << (pretty ? j.dump(2) : j.dump()) << "\n"; +} + static int run_tasks_demo() { using namespace fastmcpp; @@ -260,10 +390,7 @@ 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]); + std::vector args = collect_args(argc, argv, 2); if (consume_flag(args, "--help") || consume_flag(args, "-h")) return tasks_usage(0); @@ -284,24 +411,13 @@ static int run_tasks_command(int argc, char** argv) timeout_ms = parse_int(*t, timeout_ms); std::vector remaining = args; - auto conn = parse_tasks_connection(remaining); + auto conn = parse_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") @@ -321,7 +437,7 @@ static int run_tasks_command(int argc, char** argv) auto client = make_client_from_connection(*conn); fastmcpp::Json res = client.list_tasks_raw(cursor, limit); - dump_json(res); + dump_json(res, pretty); return 0; } @@ -350,55 +466,49 @@ static int run_tasks_command(int argc, char** argv) { auto client = make_client_from_connection(*conn); fastmcpp::Json res = client.call("tasks/get", fastmcpp::Json{{"taskId", task_id}}); - dump_json(res); + dump_json(res, pretty); 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); + fastmcpp::Json res = client.call("tasks/cancel", fastmcpp::Json{{"taskId", task_id}}); + dump_json(res, pretty); return 0; } - if (sub == "result") + auto client = make_client_from_connection(*conn); + if (wait) { - auto client = make_client_from_connection(*conn); - if (wait) + auto start = std::chrono::steady_clock::now(); + while (true) { - 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, pretty); + return 3; + } + if (timeout_ms > 0 && + std::chrono::steady_clock::now() - start >= std::chrono::milliseconds(timeout_ms)) { - 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)); + dump_json(status, pretty); + 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; } + + fastmcpp::Json res = client.call("tasks/result", fastmcpp::Json{{"taskId", task_id}}); + dump_json(res, pretty); + return 0; } std::cerr << "Unknown tasks subcommand: " << sub << "\n"; @@ -411,6 +521,1142 @@ static int run_tasks_command(int argc, char** argv) } } +static int run_discover_command(int argc, char** argv) +{ + std::vector args = collect_args(argc, argv, 2); + if (consume_flag(args, "--help") || consume_flag(args, "-h")) + { + std::cout << "Usage: fastmcpp discover [connection options] [--pretty]\n"; + return 0; + } + + bool pretty = consume_flag(args, "--pretty"); + + auto conn = parse_connection(args); + if (!conn) + { + std::cerr << "Missing connection options. See: fastmcpp --help\n"; + return 2; + } + if (auto bad = reject_unknown_flags(args); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + + try + { + auto client = make_client_from_connection(*conn); + fastmcpp::Json out = fastmcpp::Json::object(); + out["initialize"] = initialize_client(client); + + auto collect_method = [&client, &out](const std::string& key, const std::string& method) + { + try + { + out[key] = client.call(method, fastmcpp::Json::object()); + } + catch (const std::exception& e) + { + out[key] = fastmcpp::Json{{"error", e.what()}}; + } + }; + + collect_method("tools", "tools/list"); + collect_method("resources", "resources/list"); + collect_method("resourceTemplates", "resources/templates/list"); + collect_method("prompts", "prompts/list"); + + dump_json(out, pretty); + return 0; + } + catch (const std::exception& e) + { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} + +static int run_list_command(int argc, char** argv) +{ + std::vector args = collect_args(argc, argv, 2); + if (consume_flag(args, "--help") || consume_flag(args, "-h") || args.empty()) + { + std::cout << "Usage: fastmcpp list [connection options] [--pretty]\n"; + return args.empty() ? 1 : 0; + } + + std::string item = args.front(); + args.erase(args.begin()); + + bool pretty = consume_flag(args, "--pretty"); + auto conn = parse_connection(args); + if (!conn) + { + std::cerr << "Missing connection options. See: fastmcpp --help\n"; + return 2; + } + if (auto bad = reject_unknown_flags(args); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + + std::string method; + if (item == "tools") + method = "tools/list"; + else if (item == "resources") + method = "resources/list"; + else if (item == "resource-templates" || item == "templates") + method = "resources/templates/list"; + else if (item == "prompts") + method = "prompts/list"; + else + { + std::cerr << "Unknown list target: " << item << "\n"; + return 2; + } + + try + { + auto client = make_client_from_connection(*conn); + initialize_client(client); + auto result = client.call(method, fastmcpp::Json::object()); + dump_json(result, pretty); + return 0; + } + catch (const std::exception& e) + { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} + +static int run_call_command(int argc, char** argv) +{ + std::vector args = collect_args(argc, argv, 2); + if (consume_flag(args, "--help") || consume_flag(args, "-h")) + { + std::cout << "Usage: fastmcpp call [--args ] [connection options] [--pretty]\n"; + return 0; + } + if (args.empty()) + { + std::cerr << "Missing tool name\n"; + return 2; + } + + std::string tool_name = args.front(); + args.erase(args.begin()); + + bool pretty = consume_flag(args, "--pretty"); + std::string args_json = "{}"; + if (auto raw = consume_flag_value(args, "--args")) + args_json = *raw; + else if (auto raw_alt = consume_flag_value(args, "--arguments")) + args_json = *raw_alt; + + auto conn = parse_connection(args); + if (!conn) + { + std::cerr << "Missing connection options. See: fastmcpp --help\n"; + return 2; + } + if (auto bad = reject_unknown_flags(args); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + + fastmcpp::Json parsed_args; + try + { + parsed_args = fastmcpp::Json::parse(args_json); + if (!parsed_args.is_object()) + throw std::runtime_error("arguments must be a JSON object"); + } + catch (const std::exception& e) + { + std::cerr << "Invalid --args JSON: " << e.what() << "\n"; + return 2; + } + + try + { + auto client = make_client_from_connection(*conn); + initialize_client(client); + fastmcpp::Json result = + client.call("tools/call", fastmcpp::Json{{"name", tool_name}, {"arguments", parsed_args}}); + dump_json(result, pretty); + return 0; + } + catch (const std::exception& e) + { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} + +static std::string ps_quote(const std::string& s) +{ + std::string out = "'"; + for (char c : s) + { + if (c == '\'') + out += "''"; + else + out.push_back(c); + } + out.push_back('\''); + return out; +} + +static std::string join_ps_array(const std::vector& values) +{ + std::ostringstream oss; + for (size_t i = 0; i < values.size(); ++i) + { + if (i > 0) + oss << ", "; + oss << ps_quote(values[i]); + } + return oss.str(); +} + +static std::string sanitize_ps_function_name(const std::string& name) +{ + std::string out; + out.reserve(name.size()); + for (char c : name) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_') + out.push_back(c); + else + out.push_back('_'); + } + if (out.empty()) + out = "tool"; + if (out.front() >= '0' && out.front() <= '9') + out = "tool_" + out; + return out; +} + +static std::string url_encode(const std::string& value) +{ + static constexpr char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size() * 3); + for (unsigned char c : value) + { + const bool unreserved = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~'; + if (unreserved) + { + out.push_back(static_cast(c)); + continue; + } + out.push_back('%'); + out.push_back(kHex[(c >> 4) & 0x0F]); + out.push_back(kHex[c & 0x0F]); + } + return out; +} + +static std::string base64_urlsafe_encode(const std::string& input) +{ + static const char* kB64 = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + std::string out; + out.reserve(((input.size() + 2) / 3) * 4); + for (size_t i = 0; i < input.size(); i += 3) + { + uint32_t n = static_cast(input[i]) << 16; + if (i + 1 < input.size()) + n |= static_cast(input[i + 1]) << 8; + if (i + 2 < input.size()) + n |= static_cast(input[i + 2]); + + out.push_back(kB64[(n >> 18) & 0x3F]); + out.push_back(kB64[(n >> 12) & 0x3F]); + out.push_back(i + 1 < input.size() ? kB64[(n >> 6) & 0x3F] : '='); + out.push_back(i + 2 < input.size() ? kB64[n & 0x3F] : '='); + } + + for (char& c : out) + { + if (c == '+') + c = '-'; + else if (c == '/') + c = '_'; + } + + return out; +} + +static std::string shell_quote(const std::string& value) +{ + if (value.empty()) + return "\"\""; + + bool needs_quotes = false; + for (char c : value) + { + if (c == ' ' || c == '\t' || c == '"' || c == '\\') + { + needs_quotes = true; + break; + } + } + + if (!needs_quotes) + return value; + + std::string out = "\""; + for (char c : value) + { + if (c == '"') + out += "\\\""; + else + out.push_back(c); + } + out.push_back('"'); + return out; +} + +static bool starts_with(const std::string& value, const std::string& prefix) +{ + return value.size() >= prefix.size() && + value.compare(0, prefix.size(), prefix) == 0; +} + +static std::string py_quote(const std::string& s) +{ + std::string out = "'"; + for (char c : s) + { + if (c == '\\') + out += "\\\\"; + else if (c == '\'') + out += "\\'"; + else + out.push_back(c); + } + out.push_back('\''); + return out; +} + +static std::string py_list_literal(const std::vector& values) +{ + std::ostringstream out; + out << "["; + for (size_t i = 0; i < values.size(); ++i) + { + if (i > 0) + out << ", "; + out << py_quote(values[i]); + } + out << "]"; + return out.str(); +} + +static std::optional> +parse_header_assignment(const std::string& assignment) +{ + auto pos = assignment.find('='); + if (pos == std::string::npos || pos == 0) + return std::nullopt; + return std::make_pair(assignment.substr(0, pos), assignment.substr(pos + 1)); +} + +static std::string derive_server_name(const std::string& server_spec) +{ + if (starts_with(server_spec, "http://") || starts_with(server_spec, "https://")) + { + static const std::regex host_re(R"(^(https?)://([^/:]+).*$)"); + std::smatch m; + if (std::regex_match(server_spec, m, host_re) && m.size() >= 3) + return m[2].str(); + return "server"; + } + + if (server_spec.size() >= 3) + { + auto pos = server_spec.find(':'); + if (pos != std::string::npos && pos > 0 && + server_spec.find('/') == std::string::npos && + server_spec.find('\\') == std::string::npos) + { + auto suffix = server_spec.substr(pos + 1); + if (!suffix.empty()) + return suffix; + return server_spec.substr(0, pos); + } + } + + std::filesystem::path p(server_spec); + if (!p.extension().empty()) + return p.stem().string(); + return server_spec; +} + +static std::string slugify(const std::string& in) +{ + std::string out; + out.reserve(in.size()); + bool prev_dash = false; + for (unsigned char c : in) + { + if (std::isalnum(c)) + { + out.push_back(static_cast(std::tolower(c))); + prev_dash = false; + } + else if (!prev_dash) + { + out.push_back('-'); + prev_dash = true; + } + } + while (!out.empty() && out.front() == '-') + out.erase(out.begin()); + while (!out.empty() && out.back() == '-') + out.pop_back(); + if (out.empty()) + out = "server"; + return out; +} + +static fastmcpp::Json make_example_value_from_schema(const fastmcpp::Json& schema, + const std::string& fallback_key) +{ + const std::string type = schema.value("type", ""); + if (type == "boolean") + return false; + if (type == "integer") + return 0; + if (type == "number") + return 0.0; + if (type == "array") + return fastmcpp::Json::array(); + if (type == "object") + return fastmcpp::Json::object(); + if (!fallback_key.empty()) + return "<" + fallback_key + ">"; + return ""; +} + +static std::string build_tool_args_example(const fastmcpp::Json& tool) +{ + fastmcpp::Json args = fastmcpp::Json::object(); + if (!(tool.contains("inputSchema") && tool["inputSchema"].is_object() && + tool["inputSchema"].contains("properties") && tool["inputSchema"]["properties"].is_object())) + return "{}"; + + std::unordered_set required; + if (tool["inputSchema"].contains("required") && tool["inputSchema"]["required"].is_array()) + { + for (const auto& entry : tool["inputSchema"]["required"]) + { + if (entry.is_string()) + required.insert(entry.get()); + } + } + + for (const auto& [prop_name, prop_schema] : tool["inputSchema"]["properties"].items()) + { + if (!required.empty() && required.find(prop_name) == required.end()) + continue; + if (prop_schema.is_object()) + args[prop_name] = make_example_value_from_schema(prop_schema, prop_name); + else + args[prop_name] = "<" + prop_name + ">"; + } + + if (args.empty()) + return "{}"; + return args.dump(); +} + +static std::optional connection_from_server_spec(const std::string& server_spec) +{ + if (starts_with(server_spec, "http://") || starts_with(server_spec, "https://")) + { + static const std::regex re(R"(^(https?://[^/]+)(/.*)?$)"); + std::smatch m; + Connection c; + c.kind = Connection::Kind::StreamableHttp; + if (std::regex_match(server_spec, m, re)) + { + c.url_or_command = m[1].str(); + c.mcp_path = (m.size() >= 3 && m[2].matched && !m[2].str().empty()) ? m[2].str() + : "/mcp"; + } + else + { + c.url_or_command = server_spec; + c.mcp_path = "/mcp"; + } + return c; + } + + Connection c; + c.kind = Connection::Kind::Stdio; + c.url_or_command = server_spec; + c.stdio_keep_alive = true; + return c; +} + +static int run_generate_cli_command(int argc, char** argv) +{ + std::vector args = collect_args(argc, argv, 2); + if (consume_flag(args, "--help") || consume_flag(args, "-h")) + { + std::cout << "Usage: fastmcpp generate-cli [output] [--force] [--timeout ] [--auth ] [--header ] [--no-skill]\n"; + return 0; + } + + bool no_skill = consume_flag(args, "--no-skill"); + bool force = consume_flag(args, "--force"); + int timeout_seconds = 30; + if (auto timeout = consume_flag_value(args, "--timeout")) + { + timeout_seconds = parse_int(*timeout, -1); + if (timeout_seconds <= 0) + { + std::cerr << "Invalid --timeout value: " << *timeout << "\n"; + return 2; + } + } + std::string auth_mode = "none"; + if (auto auth = consume_flag_value(args, "--auth")) + auth_mode = *auth; + if (auth_mode == "bearer-env") + auth_mode = "bearer"; + if (auth_mode != "none" && auth_mode != "bearer") + { + std::cerr << "Unsupported --auth mode: " << auth_mode << " (expected: none|bearer)\n"; + return 2; + } + + auto output_path = consume_flag_value(args, "--output"); + if (!output_path) + output_path = consume_flag_value(args, "-o"); + std::vector> extra_headers; + for (const auto& assignment : consume_all_flag_values(args, "--header")) + { + auto parsed = parse_header_assignment(assignment); + if (!parsed) + { + std::cerr << "Invalid --header value (expected KEY=VALUE): " << assignment << "\n"; + return 2; + } + extra_headers.push_back(*parsed); + } + + auto conn = parse_connection(args); + if (auto bad = reject_unknown_flags(args); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + std::string server_spec; + + if (conn) + { + for (const auto& [key, value] : extra_headers) + conn->headers.emplace_back(key, value); + + if (args.size() > 1) + { + std::cerr << "Unexpected argument: " << args[1] << "\n"; + return 2; + } + if (args.size() == 1) + { + // Backward-compat: explicit connection flags may use the remaining positional as output. + if (output_path) + { + std::cerr << "Output provided both positionally and via --output\n"; + return 2; + } + output_path = args.front(); + } + server_spec = conn->url_or_command.empty() ? "connection" : conn->url_or_command; + } + else + { + if (args.empty()) + { + std::cerr << "Missing server_spec. Usage: fastmcpp generate-cli [output]\n"; + return 2; + } + server_spec = args.front(); + args.erase(args.begin()); + if (!args.empty()) + { + if (output_path) + { + std::cerr << "Output provided both positionally and via --output\n"; + return 2; + } + output_path = args.front(); + args.erase(args.begin()); + } + if (!args.empty()) + { + std::cerr << "Unexpected argument: " << args.front() << "\n"; + return 2; + } + conn = connection_from_server_spec(server_spec); + for (const auto& [key, value] : extra_headers) + conn->headers.emplace_back(key, value); + } + + if (!output_path) + output_path = "cli.py"; + + std::filesystem::path out_file(*output_path); + const std::filesystem::path skill_file = out_file.parent_path() / "SKILL.md"; + if (std::filesystem::exists(out_file) && !force) + { + std::cerr << "Output file already exists. Use --force to overwrite: " << out_file.string() + << "\n"; + return 2; + } + if (!no_skill && std::filesystem::exists(skill_file) && !force) + { + std::cerr << "Skill file already exists. Use --force to overwrite: " + << skill_file.string() << "\n"; + return 2; + } + + std::vector discovered_tools; + std::optional discover_error; + + if (conn) + { + try + { + auto client = make_client_from_connection(*conn); + initialize_client(client); + auto tools_result = client.call("tools/list", fastmcpp::Json::object()); + if (tools_result.contains("tools") && tools_result["tools"].is_array()) + { + for (const auto& tool : tools_result["tools"]) + { + if (tool.is_object() && tool.contains("name") && tool["name"].is_string()) + discovered_tools.push_back(tool); + } + } + } + catch (const std::exception& e) + { + discover_error = e.what(); + } + } + + const std::vector generated_connection = connection_to_cli_args(*conn); + const std::string server_name = derive_server_name(server_spec); + + std::ostringstream script; + script << "#!/usr/bin/env python3\n"; + script << "# CLI for " << server_name << " MCP server.\n"; + script << "# Generated by: fastmcpp generate-cli " << server_spec << "\n\n"; + script << "import argparse\n"; + script << "import json\n"; + script << "import os\n"; + script << "import subprocess\n"; + script << "import sys\n\n"; + script << "CONNECTION = " << py_list_literal(generated_connection) << "\n\n"; + script << "DEFAULT_TIMEOUT = " << timeout_seconds << "\n"; + script << "AUTH_MODE = " << py_quote(auth_mode) << "\n"; + script << "AUTH_ENV = 'FASTMCPP_AUTH_TOKEN'\n\n"; + script << "def _connection_args():\n"; + script << " args = list(CONNECTION)\n"; + script << " if AUTH_MODE == 'bearer':\n"; + script << " token = os.environ.get(AUTH_ENV, '').strip()\n"; + script << " if not token:\n"; + script << " print(f'Missing {AUTH_ENV} for --auth bearer', file=sys.stderr)\n"; + script << " raise SystemExit(2)\n"; + script << " args += ['--header', 'Authorization=Bearer ' + token]\n"; + script << " return args\n\n"; + script << "def _run(sub_args):\n"; + script << " cmd = ['fastmcpp'] + sub_args + _connection_args()\n"; + script << " try:\n"; + script << " proc = subprocess.run(cmd, capture_output=True, text=True, timeout=DEFAULT_TIMEOUT)\n"; + script << " except subprocess.TimeoutExpired:\n"; + script << " print(f'Command timed out after {DEFAULT_TIMEOUT}s', file=sys.stderr)\n"; + script << " raise SystemExit(124)\n"; + script << " if proc.stdout:\n"; + script << " print(proc.stdout, end='')\n"; + script << " if proc.stderr:\n"; + script << " print(proc.stderr, end='', file=sys.stderr)\n"; + script << " if proc.returncode != 0:\n"; + script << " raise SystemExit(proc.returncode)\n\n"; + script << "def main():\n"; + script << " parser = argparse.ArgumentParser(prog='" << out_file.filename().string() + << "', description='Generated CLI for " << server_name << "')\n"; + script << " sub = parser.add_subparsers(dest='command', required=True)\n"; + script << " sub.add_parser('discover')\n"; + script << " sub.add_parser('list-tools')\n"; + script << " sub.add_parser('list-resources')\n"; + script << " sub.add_parser('list-resource-templates')\n"; + script << " sub.add_parser('list-prompts')\n"; + script << " call = sub.add_parser('call-tool')\n"; + script << " call.add_argument('tool')\n"; + script << " call.add_argument('--args', default='{}')\n"; + script << " args = parser.parse_args()\n\n"; + script << " if args.command == 'discover':\n"; + script << " _run(['discover'])\n"; + script << " elif args.command == 'list-tools':\n"; + script << " _run(['list', 'tools'])\n"; + script << " elif args.command == 'list-resources':\n"; + script << " _run(['list', 'resources'])\n"; + script << " elif args.command == 'list-resource-templates':\n"; + script << " _run(['list', 'resource-templates'])\n"; + script << " elif args.command == 'list-prompts':\n"; + script << " _run(['list', 'prompts'])\n"; + script << " elif args.command == 'call-tool':\n"; + script << " _run(['call', args.tool, '--args', args.args])\n\n"; + script << "if __name__ == '__main__':\n"; + script << " main()\n"; + + std::ofstream out(out_file, std::ios::binary | std::ios::trunc); + if (!out) + { + std::cerr << "Failed to open output file: " << out_file.string() << "\n"; + return 1; + } + out << script.str(); + + if (!no_skill) + { + std::ofstream skill_out(skill_file, std::ios::binary | std::ios::trunc); + if (!skill_out) + { + std::cerr << "Failed to open skill file: " << skill_file.string() << "\n"; + return 1; + } + + std::ostringstream skill; + skill << "---\n"; + skill << "name: \"" << slugify(server_name) << "-cli\"\n"; + skill << "description: \"CLI for the " << server_name + << " MCP server. Call tools and list components.\"\n"; + skill << "---\n\n"; + skill << "# " << server_name << " CLI\n\n"; + + if (!discovered_tools.empty()) + { + skill << "## Tool Commands\n\n"; + for (const auto& tool : discovered_tools) + { + const std::string tool_name = tool.value("name", ""); + skill << "### " << tool_name << "\n\n"; + if (tool.contains("description") && tool["description"].is_string()) + skill << tool["description"].get() << "\n\n"; + skill << "```bash\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() + << " call-tool " << tool_name << " --args " + << shell_quote(build_tool_args_example(tool)); + skill << "\n```\n\n"; + } + } + + skill << "## Utility Commands\n\n"; + skill << "```bash\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() + << " discover\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() + << " list-tools\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() + << " list-resources\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() + << " list-prompts\n"; + skill << "```\n\n"; + + skill_out << skill.str(); + } + + std::cout << "Generated CLI script: " << out_file.string() << "\n"; + if (!no_skill) + std::cout << "Generated SKILL.md: " << skill_file.string() << "\n"; + if (discover_error) + std::cerr << "Warning: tool discovery failed: " << *discover_error << "\n"; + return 0; +} + +static std::optional parse_install_env(const std::vector& env_pairs, + std::string& error) +{ + fastmcpp::Json env = fastmcpp::Json::object(); + for (const auto& pair : env_pairs) + { + auto eq = pair.find('='); + if (eq == std::string::npos || eq == 0) + { + error = "Invalid --env value (expected KEY=VALUE): " + pair; + return std::nullopt; + } + env[pair.substr(0, eq)] = pair.substr(eq + 1); + } + return env; +} + +static bool load_env_file_into(const std::filesystem::path& env_file, fastmcpp::Json& env, + std::string& error) +{ + std::ifstream in(env_file, std::ios::binary); + if (!in) + { + error = "Failed to open --env-file: " + env_file.string(); + return false; + } + + std::string line; + int line_no = 0; + while (std::getline(in, line)) + { + ++line_no; + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + const auto first = line.find_first_not_of(" \t"); + if (first == std::string::npos) + continue; + if (line[first] == '#') + continue; + + auto eq = line.find('=', first); + if (eq == std::string::npos || eq == first) + { + error = "Invalid env file entry at line " + std::to_string(line_no) + ": " + line; + return false; + } + + std::string key = line.substr(first, eq - first); + std::string value = line.substr(eq + 1); + env[key] = value; + } + + return true; +} + +static fastmcpp::Json build_stdio_install_config(const std::string& name, const std::string& command, + const std::vector& command_args, + const fastmcpp::Json& env) +{ + fastmcpp::Json server = { + {"command", command}, + {"args", command_args}, + }; + if (!env.empty()) + server["env"] = env; + return fastmcpp::Json{{"mcpServers", fastmcpp::Json{{name, server}}}}; +} + +static std::string build_add_command(const std::string& cli, const std::string& name, + const std::string& command, + const std::vector& command_args) +{ + std::ostringstream oss; + oss << cli << " mcp add " << shell_quote(name) << " -- " << shell_quote(command); + for (const auto& arg : command_args) + oss << " " << shell_quote(arg); + return oss.str(); +} + +static std::string build_stdio_command_line(const std::string& command, + const std::vector& command_args) +{ + std::ostringstream oss; + oss << shell_quote(command); + for (const auto& arg : command_args) + oss << " " << shell_quote(arg); + return oss.str(); +} + +static bool try_copy_to_clipboard(const std::string& text) +{ +#if defined(_WIN32) + FILE* pipe = _popen("clip", "w"); + if (!pipe) + return false; + const size_t written = fwrite(text.data(), 1, text.size(), pipe); + const int rc = _pclose(pipe); + return written == text.size() && rc == 0; +#elif defined(__APPLE__) + FILE* pipe = popen("pbcopy", "w"); + if (!pipe) + return false; + const size_t written = fwrite(text.data(), 1, text.size(), pipe); + const int rc = pclose(pipe); + return written == text.size() && rc == 0; +#else + FILE* pipe = popen("wl-copy", "w"); + if (!pipe) + pipe = popen("xclip -selection clipboard", "w"); + if (!pipe) + return false; + const size_t written = fwrite(text.data(), 1, text.size(), pipe); + const int rc = pclose(pipe); + return written == text.size() && rc == 0; +#endif +} + +static int emit_install_output(const std::string& output, bool copy_mode) +{ + std::cout << output << "\n"; + if (copy_mode && !try_copy_to_clipboard(output)) + std::cerr << "Warning: --copy requested but clipboard utility is unavailable\n"; + return 0; +} + +struct InstallLaunchSpec +{ + std::string command; + std::vector args; +}; + +static InstallLaunchSpec build_launch_from_server_spec( + const std::string& server_spec, const std::vector& with_packages, + const std::vector& with_editable, const std::optional& python_version, + const std::optional& requirements_file, const std::optional& project_dir) +{ + InstallLaunchSpec spec; + spec.command = "uv"; + spec.args.push_back("run"); + spec.args.push_back("--with"); + spec.args.push_back("fastmcp"); + + for (const auto& pkg : with_packages) + { + spec.args.push_back("--with"); + spec.args.push_back(pkg); + } + + for (const auto& path : with_editable) + { + spec.args.push_back("--with-editable"); + spec.args.push_back(path); + } + + if (python_version) + { + spec.args.push_back("--python"); + spec.args.push_back(*python_version); + } + if (requirements_file) + { + spec.args.push_back("--with-requirements"); + spec.args.push_back(*requirements_file); + } + if (project_dir) + { + spec.args.push_back("--project"); + spec.args.push_back(*project_dir); + } + + spec.args.push_back("fastmcp"); + spec.args.push_back("run"); + spec.args.push_back(server_spec); + return spec; +} + +static int run_install_command(int argc, char** argv) +{ + std::vector args = collect_args(argc, argv, 2); + if (consume_flag(args, "--help") || consume_flag(args, "-h") || args.empty()) + return install_usage(args.empty() ? 1 : 0); + + std::string target = args.front(); + args.erase(args.begin()); + if (target == "json") + target = "mcp-json"; + else if (target == "claude") + target = "claude-code"; + else if (target == "gemini") + target = "gemini-cli"; + + std::optional server_spec; + if (!args.empty() && !is_flag(args.front())) + { + server_spec = args.front(); + args.erase(args.begin()); + } + + std::string server_name = "fastmcpp"; + if (auto v = consume_flag_value(args, "--name")) + server_name = *v; + + std::string command = "fastmcpp_example_stdio_mcp_server"; + if (auto v = consume_flag_value(args, "--command")) + command = *v; + + std::vector command_args; + command_args = consume_all_flag_values(args, "--arg"); + + std::vector with_packages = consume_all_flag_values(args, "--with"); + std::vector with_editable = consume_all_flag_values(args, "--with-editable"); + std::optional python_version = consume_flag_value(args, "--python"); + std::optional with_requirements = consume_flag_value(args, "--with-requirements"); + std::optional project_dir = consume_flag_value(args, "--project"); + bool copy_mode = consume_flag(args, "--copy"); + + std::vector env_pairs; + env_pairs = consume_all_flag_values(args, "--env"); + + std::optional env_file; + if (auto v = consume_flag_value(args, "--env-file")) + env_file = *v; + + std::optional workspace; + if (auto v = consume_flag_value(args, "--workspace")) + workspace = *v; + + if (auto bad = reject_unknown_flags(args); !bad.empty()) + { + std::cerr << "Unknown option: " << bad << "\n"; + return 2; + } + if (!args.empty()) + { + std::cerr << "Unexpected argument: " << args.front() << "\n"; + return 2; + } + + std::string env_error; + auto env = parse_install_env(env_pairs, env_error); + if (!env) + { + std::cerr << env_error << "\n"; + return 2; + } + + if (env_file) + { + if (!load_env_file_into(std::filesystem::path(*env_file), *env, env_error)) + { + std::cerr << env_error << "\n"; + return 2; + } + } + + if (command == "fastmcpp_example_stdio_mcp_server" && server_spec) + { + const std::vector passthrough_args = command_args; + auto launch = build_launch_from_server_spec(*server_spec, with_packages, with_editable, + python_version, with_requirements, project_dir); + command = launch.command; + command_args = launch.args; + command_args.insert(command_args.end(), passthrough_args.begin(), passthrough_args.end()); + } + + fastmcpp::Json config = build_stdio_install_config(server_name, command, command_args, *env); + fastmcpp::Json server_config = config["mcpServers"][server_name]; + + if (target == "stdio") + { + return emit_install_output(build_stdio_command_line(command, command_args), copy_mode); + } + + if (target == "mcp-json") + { + fastmcpp::Json entry = fastmcpp::Json{{server_name, server_config}}; + return emit_install_output(entry.dump(2), copy_mode); + } + + if (target == "goose") + { + return emit_install_output(build_add_command("goose", server_name, command, command_args), + copy_mode); + } + + if (target == "claude-code") + { + return emit_install_output(build_add_command("claude", server_name, command, command_args), + copy_mode); + } + + if (target == "gemini-cli") + { + return emit_install_output(build_add_command("gemini", server_name, command, command_args), + copy_mode); + } + + if (target == "claude-desktop") + { + return emit_install_output("# Add this server to your Claude Desktop MCP configuration:\n" + + config.dump(2), + copy_mode); + } + + if (target == "cursor") + { + if (workspace) + { + std::filesystem::path ws(*workspace); + std::filesystem::path cursor_dir = ws / ".cursor"; + std::filesystem::path cursor_file = cursor_dir / "mcp.json"; + + std::error_code ec; + std::filesystem::create_directories(cursor_dir, ec); + if (ec) + { + std::cerr << "Failed to create workspace cursor directory: " << cursor_dir.string() + << "\n"; + return 1; + } + + fastmcpp::Json workspace_config = fastmcpp::Json::object(); + if (std::filesystem::exists(cursor_file)) + { + std::ifstream in(cursor_file, std::ios::binary); + if (in) + { + try + { + in >> workspace_config; + } + catch (...) + { + workspace_config = fastmcpp::Json::object(); + } + } + } + if (!workspace_config.contains("mcpServers") || + !workspace_config["mcpServers"].is_object()) + workspace_config["mcpServers"] = fastmcpp::Json::object(); + workspace_config["mcpServers"][server_name] = server_config; + + std::ofstream out(cursor_file, std::ios::binary | std::ios::trunc); + if (!out) + { + std::cerr << "Failed to write cursor workspace config: " << cursor_file.string() + << "\n"; + return 1; + } + out << workspace_config.dump(2); + std::cout << "Updated cursor workspace config: " << cursor_file.string() << "\n"; + if (copy_mode && !try_copy_to_clipboard(cursor_file.string())) + std::cerr << "Warning: --copy requested but clipboard utility is unavailable\n"; + return 0; + } + + const std::string encoded_name = url_encode(server_name); + const std::string encoded_config = base64_urlsafe_encode(server_config.dump()); + return emit_install_output("cursor://anysphere.cursor-deeplink/mcp/install?name=" + + encoded_name + "&config=" + encoded_config, + copy_mode); + } + + std::cerr << "Unknown install target: " << target << "\n"; + return 2; +} + } // namespace int main(int argc, char** argv) @@ -439,6 +1685,16 @@ int main(int argc, char** argv) return usage(); } + if (cmd == "discover") + return run_discover_command(argc, argv); + if (cmd == "list") + return run_list_command(argc, argv); + if (cmd == "call") + return run_call_command(argc, argv); + if (cmd == "generate-cli") + return run_generate_cli_command(argc, argv); + if (cmd == "install") + return run_install_command(argc, argv); if (cmd == "tasks") return run_tasks_command(argc, argv); diff --git a/src/client/transports.cpp b/src/client/transports.cpp index e6f2316..3b302fd 100644 --- a/src/client/transports.cpp +++ b/src/client/transports.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -171,13 +170,16 @@ fastmcpp::Json HttpTransport::request(const std::string& route, const fastmcpp:: cli.set_connection_timeout(5, 0); cli.set_keep_alive(true); - cli.set_read_timeout(10, 0); + cli.set_read_timeout(static_cast(timeout_.count()), 0); // Security: Disable redirects by default to prevent SSRF and TLS downgrade attacks cli.set_follow_location(false); - cli.set_default_headers({{"Accept", "text/event-stream, application/json"}}); - auto res = cli.Post(("/" + route).c_str(), payload.dump(), "application/json"); + httplib::Headers headers = {{"Accept", "text/event-stream, application/json"}}; + for (const auto& [key, value] : headers_) + headers.emplace(key, value); + + auto res = cli.Post(("/" + route).c_str(), headers, payload.dump(), "application/json"); if (!res) throw fastmcpp::TransportError("HTTP request failed: no response"); if (res->status < 200 || res->status >= 300) @@ -196,10 +198,12 @@ void HttpTransport::request_stream(const std::string& route, const fastmcpp::Jso cli.set_connection_timeout(5, 0); cli.set_keep_alive(true); - cli.set_read_timeout(10, 0); + cli.set_read_timeout(static_cast(timeout_.count()), 0); std::string path = "/" + route; httplib::Headers headers = {{"Accept", "text/event-stream, application/json"}}; + for (const auto& [key, value] : headers_) + headers.emplace(key, value); std::string buffer; std::string last_emitted; @@ -324,6 +328,11 @@ void HttpTransport::request_stream_post(const std::string& route, const fastmcpp struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Accept: text/event-stream, application/json"); + for (const auto& [key, value] : headers_) + { + std::string header = key + ": " + value; + headers = curl_slist_append(headers, header.c_str()); + } std::string buffer; auto parse_and_emit = [&](bool flush_all = false) @@ -413,7 +422,8 @@ void HttpTransport::request_stream_post(const std::string& route, const fastmcpp }); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buffer); curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 1L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); // no overall timeout + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, static_cast(timeout_.count())); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); // no overall timeout for streaming CURLcode code = curl_easy_perform(curl); long status = 0; @@ -440,93 +450,6 @@ void HttpTransport::request_stream_post(const std::string& route, const fastmcpp #endif } -fastmcpp::Json WebSocketTransport::request(const std::string& route, const fastmcpp::Json& payload) -{ - using easywsclient::WebSocket; - std::string full = url_; - if (!full.empty() && full.back() != '/') - full.push_back('/'); - full += route; - std::unique_ptr ws(WebSocket::from_url(full)); - if (!ws) - throw fastmcpp::TransportError("WS connect failed: " + full); - ws->send(payload.dump()); - std::string resp; - bool got = false; - auto onmsg = [&](const std::string& msg) - { - resp = msg; - got = true; - }; - // Wait up to ~2s - for (int i = 0; i < 40 && !got; ++i) - { - ws->poll(50); - ws->dispatch(onmsg); - } - ws->close(); - if (!got) - throw fastmcpp::TransportError("WS no response"); - return fastmcpp::util::json::parse(resp); -} - -void WebSocketTransport::request_stream(const std::string& route, const fastmcpp::Json& payload, - const std::function& on_event) -{ - using easywsclient::WebSocket; - std::string full = url_; - if (!full.empty() && full.back() != '/') - full.push_back('/'); - full += route; - std::unique_ptr ws(WebSocket::from_url(full)); - if (!ws) - throw fastmcpp::TransportError("WS connect failed: " + full); - - // Send initial payload - ws->send(payload.dump()); - - // Pump loop: dispatch frames for a reasonable period or until closed - // Stop after a short idle timeout window to avoid hanging indefinitely - std::string frame; - auto onmsg = [&](const std::string& msg) { frame = msg; }; - - const int max_iters = 400; // ~20s total at 50ms per poll - int idle_iters = 0; - for (int i = 0; i < max_iters; ++i) - { - ws->poll(50); - frame.clear(); - ws->dispatch(onmsg); - if (!frame.empty()) - { - try - { - auto evt = fastmcpp::util::json::parse(frame); - if (on_event) - on_event(evt); - } - catch (...) - { - fastmcpp::Json item = fastmcpp::Json{{"type", "text"}, {"text", frame}}; - fastmcpp::Json evt = fastmcpp::Json{{"content", fastmcpp::Json::array({item})}}; - if (on_event) - on_event(evt); - } - idle_iters = 0; // reset idle counter on data - } - else - { - // No message arrived in this poll slice - if (++idle_iters > 60) - { - // ~3s idle without frames → assume stream done - break; - } - } - } - ws->close(); -} - StdioTransport::StdioTransport(std::string command, std::vector args, std::optional log_file, bool keep_alive) : command_(std::move(command)), args_(std::move(args)), log_file_(std::move(log_file)), diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index 1639865..684c0a1 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -5,6 +5,8 @@ #include "fastmcpp/proxy.hpp" #include "fastmcpp/server/sse_server.hpp" #include "fastmcpp/telemetry.hpp" +#include "fastmcpp/util/pagination.hpp" +#include "fastmcpp/version.hpp" #include #include @@ -26,6 +28,103 @@ namespace fastmcpp::mcp { +// MCP spec error codes (SEP-compliant) +static constexpr int kJsonRpcMethodNotFound = -32601; +static constexpr int kJsonRpcInvalidParams = -32602; +static constexpr int kJsonRpcInternalError = -32603; +static constexpr int kMcpMethodNotFound = -32001; // MCP "Method not found" +static constexpr int kMcpResourceNotFound = -32002; // MCP "Resource not found" +static constexpr int kMcpToolTimeout = -32000; +static constexpr const char* kUiExtensionId = "io.modelcontextprotocol/ui"; + +// Helper: create fastmcp metadata namespace (parity with Python fastmcp 53e220a9) +static fastmcpp::Json make_fastmcp_meta() +{ + return fastmcpp::Json{ + {"version", std::to_string(fastmcpp::VERSION_MAJOR) + "." + + std::to_string(fastmcpp::VERSION_MINOR) + "." + + std::to_string(fastmcpp::VERSION_PATCH)}}; +} + +static fastmcpp::Json merge_meta_with_ui(const std::optional& meta, + const std::optional& app) +{ + fastmcpp::Json merged = meta && meta->is_object() ? *meta : fastmcpp::Json::object(); + if (app && !app->empty()) + merged["ui"] = *app; + return merged; +} + +static void attach_meta_ui(fastmcpp::Json& entry, const std::optional& app, + const std::optional& meta = std::nullopt) +{ + fastmcpp::Json merged = merge_meta_with_ui(meta, app); + if (!merged.empty()) + entry["_meta"] = std::move(merged); +} + +static std::string normalize_resource_uri(std::string uri) +{ + while (uri.size() > 1 && !uri.empty() && uri.back() == '/') + uri.pop_back(); + return uri; +} + +static std::optional find_resource_app_config(const FastMCP& app, + const std::string& uri) +{ + const std::string normalized = normalize_resource_uri(uri); + for (const auto& resource : app.list_all_resources()) + { + if (!resource.app || resource.app->empty()) + continue; + if (normalize_resource_uri(resource.uri) == normalized) + return resource.app; + } + + for (const auto& templ : app.list_all_templates()) + { + if (!templ.app || templ.app->empty()) + continue; + if (templ.match(normalized).has_value()) + return templ.app; + } + return std::nullopt; +} + +static void attach_resource_content_meta_ui(fastmcpp::Json& content_json, const FastMCP& app, + const std::string& request_uri) +{ + auto app_cfg = find_resource_app_config(app, request_uri); + if (!app_cfg) + return; + fastmcpp::Json meta = + content_json.contains("_meta") && content_json["_meta"].is_object() + ? content_json["_meta"] + : fastmcpp::Json::object(); + meta["ui"] = *app_cfg; + if (!meta.empty()) + content_json["_meta"] = std::move(meta); +} + +static void advertise_ui_extension(fastmcpp::Json& capabilities) +{ + if (!capabilities.contains("extensions") || !capabilities["extensions"].is_object()) + capabilities["extensions"] = fastmcpp::Json::object(); + capabilities["extensions"][kUiExtensionId] = fastmcpp::Json::object(); +} + +static void inject_client_extensions_meta(fastmcpp::Json& args, + const fastmcpp::server::ServerSession& session) +{ + auto caps = session.capabilities(); + if (!caps.contains("extensions") || !caps["extensions"].is_object()) + return; + if (!args.contains("_meta") || !args["_meta"].is_object()) + args["_meta"] = fastmcpp::Json::object(); + args["_meta"]["client_extensions"] = caps["extensions"]; +} + static fastmcpp::Json jsonrpc_error(const fastmcpp::Json& id, int code, const std::string& message) { return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -36,8 +135,29 @@ static fastmcpp::Json jsonrpc_error(const fastmcpp::Json& id, int code, const st static fastmcpp::Json jsonrpc_tool_error(const fastmcpp::Json& id, const std::exception& e) { if (dynamic_cast(&e)) - return jsonrpc_error(id, -32000, e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kMcpToolTimeout, e.what()); + if (dynamic_cast(&e)) + return jsonrpc_error(id, kJsonRpcInvalidParams, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); +} + +/// Apply pagination to a JSON array, returning a result object with the key and optional nextCursor +static fastmcpp::Json apply_pagination(const fastmcpp::Json& items, const std::string& key, + const fastmcpp::Json& params, int page_size) +{ + fastmcpp::Json result_obj = {{key, items}}; + if (page_size <= 0) + return result_obj; + + std::string cursor_str = params.value("cursor", std::string{}); + auto cursor = cursor_str.empty() ? std::nullopt : std::optional{cursor_str}; + std::vector vec(items.begin(), items.end()); + auto paginated = util::pagination::paginate_sequence(vec, cursor, page_size); + + result_obj[key] = paginated.items; + if (paginated.next_cursor.has_value()) + result_obj["nextCursor"] = *paginated.next_cursor; + return result_obj; } static bool schema_is_object(const fastmcpp::Json& schema) @@ -99,7 +219,10 @@ make_tool_entry(const std::string& name, const std::string& description, const std::optional& title = std::nullopt, const std::optional>& icons = std::nullopt, const fastmcpp::Json& output_schema = fastmcpp::Json(), - fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden) + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden, + bool sequential = false, + const std::optional& app = std::nullopt, + const std::optional& meta = std::nullopt) { fastmcpp::Json entry = { {"name", name}, @@ -115,8 +238,15 @@ make_tool_entry(const std::string& name, const std::string& description, entry["inputSchema"] = fastmcpp::Json::object(); if (!output_schema.is_null() && !output_schema.empty()) entry["outputSchema"] = normalize_output_schema_for_mcp(output_schema); - if (task_support != fastmcpp::TaskSupport::Forbidden) - entry["execution"] = fastmcpp::Json{{"taskSupport", fastmcpp::to_string(task_support)}}; + if (task_support != fastmcpp::TaskSupport::Forbidden || sequential) + { + fastmcpp::Json execution = fastmcpp::Json::object(); + if (task_support != fastmcpp::TaskSupport::Forbidden) + execution["taskSupport"] = fastmcpp::to_string(task_support); + if (sequential) + execution["concurrency"] = "sequential"; + entry["execution"] = execution; + } // Add icons if present if (icons && !icons->empty()) { @@ -132,6 +262,8 @@ make_tool_entry(const std::string& name, const std::string& description, } entry["icons"] = icons_json; } + attach_meta_ui(entry, app, meta); + entry["fastmcp"] = make_fastmcp_meta(); return entry; } @@ -842,9 +974,10 @@ make_mcp_handler(const std::string& server_name, const std::string& version, else if (tool.description()) desc = *tool.description(); - tools_array.push_back(make_tool_entry(name, desc, schema, tool.title(), - tool.icons(), tool.output_schema(), - tool.task_support())); + tools_array.push_back( + make_tool_entry(name, desc, schema, tool.title(), tool.icons(), + tool.output_schema(), tool.task_support(), + tool.sequential(), tool.app())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -857,7 +990,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, std::string name = params.value("name", ""); fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", server_name, "tool", name, extract_request_meta(params), @@ -922,12 +1055,12 @@ make_mcp_handler(const std::string& server_name, const std::string& version, // fall through to not found } - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32601, + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } @@ -1021,7 +1154,7 @@ std::function make_mcp_handler( std::string name = params.value("name", ""); fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", server.name(), "tool", name, extract_request_meta(params), @@ -1124,15 +1257,15 @@ std::function make_mcp_handler( } catch (const std::exception& e) { - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32601, + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } @@ -1212,29 +1345,11 @@ make_mcp_handler(const std::string& server_name, const std::string& version, for (const auto& name : tools.list_names()) { const auto& tool = tools.get(name); - fastmcpp::Json tool_json = {{"name", name}, - {"inputSchema", tool.input_schema()}}; - - // Add optional fields from Tool - if (tool.title()) - tool_json["title"] = *tool.title(); - if (tool.description()) - tool_json["description"] = *tool.description(); - if (tool.icons() && !tool.icons()->empty()) - { - fastmcpp::Json icons_json = fastmcpp::Json::array(); - for (const auto& icon : *tool.icons()) - { - fastmcpp::Json icon_obj = {{"src", icon.src}}; - if (icon.mime_type) - icon_obj["mimeType"] = *icon.mime_type; - if (icon.sizes) - icon_obj["sizes"] = *icon.sizes; - icons_json.push_back(icon_obj); - } - tool_json["icons"] = icons_json; - } - tools_array.push_back(tool_json); + std::string desc = tool.description() ? *tool.description() : ""; + tools_array.push_back( + make_tool_entry(name, desc, tool.input_schema(), tool.title(), + tool.icons(), tool.output_schema(), tool.task_support(), + tool.sequential(), tool.app())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1246,7 +1361,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, std::string name = params.value("name", ""); fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", server.name(), "tool", name, extract_request_meta(params), @@ -1338,11 +1453,11 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } } - return jsonrpc_error(id, -32601, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } @@ -1412,7 +1527,8 @@ make_mcp_handler(const std::string& server_name, const std::string& version, std::string desc = tool.description() ? *tool.description() : ""; tools_array.push_back( make_tool_entry(name, desc, tool.input_schema(), tool.title(), tool.icons(), - tool.output_schema(), tool.task_support())); + tool.output_schema(), tool.task_support(), + tool.sequential(), tool.app())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1424,7 +1540,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, std::string name = params.value("name", ""); fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", server.name(), "tool", name, extract_request_meta(params), @@ -1477,6 +1593,8 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } res_json["icons"] = icons_json; } + attach_meta_ui(res_json, res.app); + res_json["fastmcp"] = make_fastmcp_meta(); resources_array.push_back(res_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -1514,6 +1632,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } templ_json["icons"] = icons_json; } + attach_meta_ui(templ_json, templ.app); templ_json["parameters"] = templ.parameters.is_null() ? fastmcpp::Json::object() : templ.parameters; templates_array.push_back(templ_json); @@ -1528,7 +1647,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, { std::string uri = params.value("uri", ""); if (uri.empty()) - return jsonrpc_error(id, -32602, "Missing resource URI"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing resource URI"); // Strip trailing slashes for compatibility with Python fastmcp while (!uri.empty() && uri.back() == '/') uri.pop_back(); @@ -1578,7 +1697,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } catch (const NotFoundError& e) { - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpResourceNotFound, e.what()); } catch (const std::exception& e) { @@ -1608,6 +1727,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } prompt_json["arguments"] = args_array; } + prompt_json["fastmcp"] = make_fastmcp_meta(); prompts_array.push_back(prompt_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -1619,7 +1739,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, { std::string name = params.value("name", ""); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing prompt name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing prompt name"); auto span = telemetry::server_span( "prompt " + name, "prompts/get", server.name(), "prompt", name, extract_request_meta(params), @@ -1645,7 +1765,7 @@ make_mcp_handler(const std::string& server_name, const std::string& version, { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpMethodNotFound, e.what()); } catch (const std::exception& e) { @@ -1655,11 +1775,11 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } } - return jsonrpc_error(id, -32601, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } @@ -1673,8 +1793,9 @@ std::function make_mcp_handler(const Fast std::function make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) { - auto tasks = std::make_shared(std::move(session_accessor)); - return [&app, tasks](const fastmcpp::Json& message) -> fastmcpp::Json + auto task_session_accessor = session_accessor; + auto tasks = std::make_shared(std::move(task_session_accessor)); + return [&app, tasks, session_accessor](const fastmcpp::Json& message) -> fastmcpp::Json { try { @@ -1685,6 +1806,13 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) if (method == "initialize") { + if (!session_id.empty() && session_accessor) + { + auto session = session_accessor(session_id); + if (session && params.contains("capabilities")) + session->set_capabilities(params["capabilities"]); + } + fastmcpp::Json serverInfo = {{"name", app.name()}, {"version", app.version()}}; if (app.website_url()) serverInfo["websiteUrl"] = *app.website_url(); @@ -1708,6 +1836,7 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) capabilities["resources"] = fastmcpp::Json::object(); if (!app.list_all_prompts().empty()) capabilities["prompts"] = fastmcpp::Json::object(); + advertise_ui_extension(capabilities); return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1753,11 +1882,12 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } tool_json["icons"] = icons_json; } + attach_meta_ui(tool_json, tool_info.app, tool_info._meta); tools_array.push_back(tool_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"tools", tools_array}}}}; + {"result", apply_pagination(tools_array, "tools", params, app.list_page_size())}}; } if (method == "tools/call") @@ -1765,13 +1895,26 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) std::string name = params.value("name", ""); fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", app.name(), "tool", name, extract_request_meta(params), session_id.empty() ? std::nullopt : std::optional(session_id)); try { + if (!session_id.empty()) + { + if (!args.contains("_meta") || !args["_meta"].is_object()) + args["_meta"] = fastmcpp::Json::object(); + args["_meta"]["session_id"] = session_id; + if (session_accessor) + { + auto session = session_accessor(session_id); + if (session) + inject_client_extensions_meta(args, *session); + } + } + bool has_output_schema = false; for (const auto& tool_info : app.list_all_tools_info()) { @@ -1802,10 +1945,10 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) if (support) { if (has_task_meta && *support == fastmcpp::TaskSupport::Forbidden) - return jsonrpc_error(id, -32601, + return jsonrpc_error(id, kJsonRpcMethodNotFound, "Task execution forbidden for tool: " + name); if (!has_task_meta && *support == fastmcpp::TaskSupport::Required) - return jsonrpc_error(id, -32601, + return jsonrpc_error(id, kJsonRpcMethodNotFound, "Task execution required for tool: " + name); } @@ -1869,11 +2012,11 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) { std::string task_id = params.value("taskId", ""); if (task_id.empty()) - return jsonrpc_error(id, -32602, "Missing taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing taskId"); auto info = tasks->get_task(task_id); if (!info) - return jsonrpc_error(id, -32602, "Invalid taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Invalid taskId"); fastmcpp::Json status_json = { {"taskId", info->task_id}, @@ -1899,18 +2042,18 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) { std::string task_id = params.value("taskId", ""); if (task_id.empty()) - return jsonrpc_error(id, -32602, "Missing taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing taskId"); auto q = tasks->get_result(task_id); if (q.state == TaskRegistry::ResultState::NotFound) - return jsonrpc_error(id, -32602, "Invalid taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Invalid taskId"); if (q.state == TaskRegistry::ResultState::NotReady) - return jsonrpc_error(id, -32602, "Task not completed"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Task not completed"); if (q.state == TaskRegistry::ResultState::Cancelled) return jsonrpc_error( - id, -32603, q.error_message.empty() ? "Task cancelled" : q.error_message); + id, kJsonRpcInternalError, q.error_message.empty() ? "Task cancelled" : q.error_message); if (q.state == TaskRegistry::ResultState::Failed) - return jsonrpc_error(id, -32603, + return jsonrpc_error(id, kJsonRpcInternalError, q.error_message.empty() ? "Task failed" : q.error_message); return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, {"result", q.payload}}; @@ -1946,14 +2089,14 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) { std::string task_id = params.value("taskId", ""); if (task_id.empty()) - return jsonrpc_error(id, -32602, "Missing taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing taskId"); if (!tasks->cancel(task_id)) - return jsonrpc_error(id, -32602, "Invalid taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Invalid taskId"); auto info = tasks->get_task(task_id); if (!info) - return jsonrpc_error(id, -32602, "Invalid taskId"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Invalid taskId"); fastmcpp::Json result = { {"taskId", info->task_id}, @@ -2004,11 +2147,13 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } res_json["icons"] = icons_json; } + attach_meta_ui(res_json, res.app); + res_json["fastmcp"] = make_fastmcp_meta(); resources_array.push_back(res_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"resources", resources_array}}}}; + {"result", apply_pagination(resources_array, "resources", params, app.list_page_size())}}; } if (method == "resources/templates/list") @@ -2040,6 +2185,7 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } templ_json["icons"] = icons_json; } + attach_meta_ui(templ_json, templ.app); templ_json["parameters"] = templ.parameters.is_null() ? fastmcpp::Json::object() : templ.parameters; templates_array.push_back(templ_json); @@ -2047,14 +2193,14 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) return fastmcpp::Json{ {"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"resourceTemplates", templates_array}}}}; + {"result", apply_pagination(templates_array, "resourceTemplates", params, app.list_page_size())}}; } if (method == "resources/read") { std::string uri = params.value("uri", ""); if (uri.empty()) - return jsonrpc_error(id, -32602, "Missing resource URI"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing resource URI"); while (!uri.empty() && uri.back() == '/') uri.pop_back(); auto span = telemetry::server_span( @@ -2070,10 +2216,10 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) if (support) { if (as_task && *support == fastmcpp::TaskSupport::Forbidden) - return jsonrpc_error(id, -32601, + return jsonrpc_error(id, kJsonRpcMethodNotFound, "Task execution forbidden for resource: " + uri); if (!as_task && *support == fastmcpp::TaskSupport::Required) - return jsonrpc_error(id, -32601, + return jsonrpc_error(id, kJsonRpcMethodNotFound, "Task execution required for resource: " + uri); } @@ -2127,6 +2273,7 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } content_json["blob"] = b64; } + attach_resource_content_meta_ui(content_json, app, uri); return fastmcpp::Json{ {"contents", fastmcpp::Json::array({content_json})}}; @@ -2183,6 +2330,7 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } content_json["blob"] = b64; } + attach_resource_content_meta_ui(content_json, app, uri); fastmcpp::Json result_payload = fastmcpp::Json{{"contents", fastmcpp::Json::array({content_json})}}; @@ -2194,13 +2342,13 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpResourceNotFound, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_tool_error(id, e); } } @@ -2229,18 +2377,19 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) prompt_json["arguments"] = args_array; } } + prompt_json["fastmcp"] = make_fastmcp_meta(); prompts_array.push_back(prompt_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"prompts", prompts_array}}}}; + {"result", apply_pagination(prompts_array, "prompts", params, app.list_page_size())}}; } if (method == "prompts/get") { std::string name = params.value("name", ""); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing prompt name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing prompt name"); auto span = telemetry::server_span( "prompt " + name, "prompts/get", app.name(), "prompt", name, extract_request_meta(params), @@ -2254,10 +2403,10 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) if (support) { if (as_task && *support == fastmcpp::TaskSupport::Forbidden) - return jsonrpc_error(id, -32601, + return jsonrpc_error(id, kJsonRpcMethodNotFound, "Task execution forbidden for prompt: " + name); if (!as_task && *support == fastmcpp::TaskSupport::Required) - return jsonrpc_error(id, -32601, + return jsonrpc_error(id, kJsonRpcMethodNotFound, "Task execution required for prompt: " + name); } @@ -2335,21 +2484,21 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpMethodNotFound, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_tool_error(id, e); } } - return jsonrpc_error(id, -32601, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } @@ -2376,6 +2525,7 @@ std::function make_mcp_handler(const Prox capabilities["resources"] = fastmcpp::Json::object(); if (!app.list_all_prompts().empty()) capabilities["prompts"] = fastmcpp::Json::object(); + advertise_ui_extension(capabilities); return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -2418,6 +2568,7 @@ std::function make_mcp_handler(const Prox } tool_json["icons"] = icons_array; } + attach_meta_ui(tool_json, tool.app, tool._meta); tools_array.push_back(tool_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -2430,7 +2581,7 @@ std::function make_mcp_handler(const Prox std::string name = params.value("name", ""); fastmcpp::Json arguments = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", app.name(), "tool", name, extract_request_meta(params), @@ -2478,13 +2629,13 @@ std::function make_mcp_handler(const Prox } catch (const NotFoundError& e) { - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kJsonRpcInvalidParams, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } } @@ -2517,6 +2668,8 @@ std::function make_mcp_handler(const Prox } res_json["icons"] = icons_json; } + attach_meta_ui(res_json, res.app, res._meta); + res_json["fastmcp"] = make_fastmcp_meta(); resources_array.push_back(res_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -2553,6 +2706,7 @@ std::function make_mcp_handler(const Prox } templ_json["icons"] = icons_json; } + attach_meta_ui(templ_json, templ.app, templ._meta); if (templ.parameters) templ_json["parameters"] = *templ.parameters; else @@ -2569,7 +2723,7 @@ std::function make_mcp_handler(const Prox { std::string uri = params.value("uri", ""); if (uri.empty()) - return jsonrpc_error(id, -32602, "Missing resource URI"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing resource URI"); auto span = telemetry::server_span( "resource " + uri, "resources/read", app.name(), "resource", uri, extract_request_meta(params), @@ -2587,6 +2741,8 @@ std::function make_mcp_handler(const Prox if (text_content->mimeType) content_json["mimeType"] = *text_content->mimeType; content_json["text"] = text_content->text; + if (text_content->_meta) + content_json["_meta"] = *text_content->_meta; contents_array.push_back(content_json); } else if (auto* blob_content = @@ -2596,6 +2752,8 @@ std::function make_mcp_handler(const Prox if (blob_content->mimeType) content_json["mimeType"] = *blob_content->mimeType; content_json["blob"] = blob_content->blob; + if (blob_content->_meta) + content_json["_meta"] = *blob_content->_meta; contents_array.push_back(content_json); } } @@ -2608,13 +2766,13 @@ std::function make_mcp_handler(const Prox { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpResourceNotFound, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } } @@ -2640,6 +2798,7 @@ std::function make_mcp_handler(const Prox } prompt_json["arguments"] = args_array; } + prompt_json["fastmcp"] = make_fastmcp_meta(); prompts_array.push_back(prompt_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -2651,7 +2810,7 @@ std::function make_mcp_handler(const Prox { std::string name = params.value("name", ""); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing prompt name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing prompt name"); auto span = telemetry::server_span( "prompt " + name, "prompts/get", app.name(), "prompt", name, extract_request_meta(params), @@ -2707,21 +2866,21 @@ std::function make_mcp_handler(const Prox { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpMethodNotFound, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } } - return jsonrpc_error(id, -32601, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } @@ -2829,6 +2988,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces capabilities["resources"] = fastmcpp::Json::object(); if (!app.list_all_prompts().empty()) capabilities["prompts"] = fastmcpp::Json::object(); + advertise_ui_extension(capabilities); return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -2874,11 +3034,12 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } tool_json["icons"] = icons_json; } + attach_meta_ui(tool_json, tool_info.app, tool_info._meta); tools_array.push_back(tool_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"tools", tools_array}}}}; + {"result", apply_pagination(tools_array, "tools", params, app.list_page_size())}}; } if (method == "tools/call") @@ -2886,7 +3047,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces std::string name = params.value("name", ""); fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); if (name.empty()) - return jsonrpc_error(id, -32602, "Missing tool name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing tool name"); auto span = telemetry::server_span( "tool " + name, "tools/call", app.name(), "tool", name, extract_request_meta(params), @@ -2914,6 +3075,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces { // Store sampling context that tool can access args["_meta"]["sampling_enabled"] = true; + inject_client_extensions_meta(args, *session); } } @@ -2929,7 +3091,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } } @@ -2965,11 +3127,13 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } res_json["icons"] = icons_json; } + attach_meta_ui(res_json, res.app); + res_json["fastmcp"] = make_fastmcp_meta(); resources_array.push_back(res_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"resources", resources_array}}}}; + {"result", apply_pagination(resources_array, "resources", params, app.list_page_size())}}; } if (method == "resources/templates/list") @@ -3001,6 +3165,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } templ_json["icons"] = icons_json; } + attach_meta_ui(templ_json, templ.app); templ_json["parameters"] = templ.parameters.is_null() ? fastmcpp::Json::object() : templ.parameters; templates_array.push_back(templ_json); @@ -3008,14 +3173,14 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces return fastmcpp::Json{ {"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"resourceTemplates", templates_array}}}}; + {"result", apply_pagination(templates_array, "resourceTemplates", params, app.list_page_size())}}; } if (method == "resources/read") { std::string uri = params.value("uri", ""); if (uri.empty()) - return jsonrpc_error(id, -32602, "Missing resource URI"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing resource URI"); while (!uri.empty() && uri.back() == '/') uri.pop_back(); auto span = telemetry::server_span( @@ -3055,6 +3220,7 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } content_json["blob"] = b64; } + attach_resource_content_meta_ui(content_json, app, uri); return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -3064,13 +3230,13 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpResourceNotFound, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } } @@ -3096,18 +3262,19 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } prompt_json["arguments"] = args_array; } + prompt_json["fastmcp"] = make_fastmcp_meta(); prompts_array.push_back(prompt_json); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", fastmcpp::Json{{"prompts", prompts_array}}}}; + {"result", apply_pagination(prompts_array, "prompts", params, app.list_page_size())}}; } if (method == "prompts/get") { std::string prompt_name = params.value("name", ""); if (prompt_name.empty()) - return jsonrpc_error(id, -32602, "Missing prompt name"); + return jsonrpc_error(id, kJsonRpcInvalidParams, "Missing prompt name"); auto span = telemetry::server_span( "prompt " + prompt_name, "prompts/get", app.name(), "prompt", prompt_name, extract_request_meta(params), @@ -3142,21 +3309,21 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32602, e.what()); + return jsonrpc_error(id, kMcpMethodNotFound, e.what()); } catch (const std::exception& e) { if (span.active()) span.span().record_exception(e.what()); - return jsonrpc_error(id, -32603, e.what()); + return jsonrpc_error(id, kJsonRpcInternalError, e.what()); } } - return jsonrpc_error(id, -32601, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), -32603, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); } }; } diff --git a/src/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp new file mode 100644 index 0000000..2322b52 --- /dev/null +++ b/src/providers/openapi_provider.cpp @@ -0,0 +1,465 @@ +#include "fastmcpp/providers/openapi_provider.hpp" + +#include "fastmcpp/exceptions.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastmcpp::providers +{ + +namespace +{ +struct ParsedBaseUrl +{ + std::string scheme; + std::string host; + int port{80}; + std::string base_path; +}; + +ParsedBaseUrl parse_base_url(const std::string& url) +{ + std::regex pattern(R"(^(https?)://([^/:]+)(?::(\d+))?(/.*)?$)"); + std::smatch match; + if (!std::regex_match(url, match, pattern)) + throw ValidationError("OpenAPIProvider requires base_url like http://host[:port][/path]"); + + const std::string scheme = match[1].str(); + if (scheme != "http" && scheme != "https") + throw ValidationError("OpenAPIProvider currently supports http:// and https:// base URLs"); + + ParsedBaseUrl parsed; + parsed.scheme = scheme; + parsed.host = match[2].str(); + parsed.port = match[3].matched ? std::stoi(match[3].str()) : (scheme == "https" ? 443 : 80); + parsed.base_path = match[4].matched ? match[4].str() : std::string(); + if (!parsed.base_path.empty() && parsed.base_path.back() == '/') + parsed.base_path.pop_back(); + return parsed; +} + +std::string url_encode_component(const std::string& value) +{ + static constexpr char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size() * 3); + for (unsigned char c : value) + { + const bool unreserved = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~'; + if (unreserved) + { + out.push_back(static_cast(c)); + continue; + } + out.push_back('%'); + out.push_back(kHex[(c >> 4) & 0x0F]); + out.push_back(kHex[c & 0x0F]); + } + return out; +} + +std::string to_string_value(const Json& value) +{ + if (value.is_string()) + return value.get(); + if (value.is_boolean()) + return value.get() ? "true" : "false"; + if (value.is_number_integer()) + return std::to_string(value.get()); + if (value.is_number_unsigned()) + return std::to_string(value.get()); + if (value.is_number_float()) + return std::to_string(value.get()); + return value.dump(); +} +} // namespace + +OpenAPIProvider::OpenAPIProvider(Json openapi_spec, std::optional base_url, + Options options) + : openapi_spec_(std::move(openapi_spec)), options_(std::move(options)) +{ + if (!openapi_spec_.is_object()) + throw ValidationError("OpenAPI specification must be a JSON object"); + + if (!base_url) + { + if (openapi_spec_.contains("servers") && openapi_spec_["servers"].is_array() && + !openapi_spec_["servers"].empty() && openapi_spec_["servers"][0].is_object() && + openapi_spec_["servers"][0].contains("url") && + openapi_spec_["servers"][0]["url"].is_string()) + base_url = openapi_spec_["servers"][0]["url"].get(); + } + if (!base_url || base_url->empty()) + throw ValidationError("OpenAPIProvider requires base_url or servers[0].url in spec"); + + base_url_ = *base_url; + if (openapi_spec_.contains("info") && openapi_spec_["info"].is_object() && + openapi_spec_["info"].contains("version") && openapi_spec_["info"]["version"].is_string()) + spec_version_ = openapi_spec_["info"]["version"].get(); + + routes_ = parse_routes(); + for (const auto& route : routes_) + { + tools::Tool tool(route.tool_name, route.input_schema, route.output_schema, + [this, route](const Json& args) { return invoke_route(route, args); }); + if (route.description && !route.description->empty()) + tool.set_description(*route.description); + if (spec_version_) + tool.set_version(*spec_version_); + tools_.push_back(std::move(tool)); + } +} + +OpenAPIProvider OpenAPIProvider::from_file(const std::string& file_path, + std::optional base_url, + Options options) +{ + std::ifstream in(std::filesystem::path(file_path), std::ios::binary); + if (!in) + throw ValidationError("Unable to open OpenAPI file: " + file_path); + + std::ostringstream ss; + ss << in.rdbuf(); + Json spec; + try + { + spec = Json::parse(ss.str()); + } + catch (const std::exception& e) + { + throw ValidationError("Invalid OpenAPI JSON: " + std::string(e.what())); + } + return OpenAPIProvider(std::move(spec), std::move(base_url), std::move(options)); +} + +std::string OpenAPIProvider::slugify(const std::string& text) +{ + std::string out; + out.reserve(text.size()); + bool prev_us = false; + for (unsigned char c : text) + { + if (std::isalnum(c)) + { + out.push_back(static_cast(std::tolower(c))); + prev_us = false; + } + else if (!prev_us) + { + out.push_back('_'); + prev_us = true; + } + } + while (!out.empty() && out.front() == '_') + out.erase(out.begin()); + while (!out.empty() && out.back() == '_') + out.pop_back(); + if (out.empty()) + out = "openapi_tool"; + return out; +} + +std::string OpenAPIProvider::normalize_method(const std::string& method) +{ + std::string upper = method; + std::transform(upper.begin(), upper.end(), upper.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + return upper; +} + +std::vector OpenAPIProvider::parse_routes() const +{ + if (!openapi_spec_.contains("paths") || !openapi_spec_["paths"].is_object()) + throw ValidationError("OpenAPI specification is missing 'paths' object"); + + std::vector routes; + std::unordered_map name_counts; + static const std::vector methods = {"get", "post", "put", "patch", "delete"}; + + for (const auto& [path, path_obj] : openapi_spec_["paths"].items()) + { + if (!path_obj.is_object()) + continue; + + Json path_params = Json::array(); + if (path_obj.contains("parameters") && path_obj["parameters"].is_array()) + path_params = path_obj["parameters"]; + + for (const auto& method : methods) + { + if (!path_obj.contains(method) || !path_obj[method].is_object()) + continue; + + const auto& op = path_obj[method]; + RouteDefinition route; + route.method = normalize_method(method); + route.path = path; + + const std::string operation_id = op.value("operationId", ""); + std::string base_name = operation_id; + if (base_name.empty()) + base_name = method + "_" + path; + auto it = options_.mcp_names.find(operation_id); + if (!operation_id.empty() && it != options_.mcp_names.end() && !it->second.empty()) + base_name = it->second; + base_name = slugify(base_name); + + int& count = name_counts[base_name]; + ++count; + route.tool_name = count == 1 ? base_name : base_name + "_" + std::to_string(count); + + if (op.contains("description") && op["description"].is_string()) + route.description = op["description"].get(); + else if (op.contains("summary") && op["summary"].is_string()) + route.description = op["summary"].get(); + + Json properties = Json::object(); + Json required = Json::array(); + + struct ParsedParameter + { + std::string name; + std::string location; + Json schema; + bool required{false}; + }; + + std::vector parsed_parameters; + std::vector parameter_order; + std::unordered_map parameter_indices; + + auto consume_parameters = [&](const Json& params) + { + if (!params.is_array()) + return; + for (const auto& param : params) + { + if (!param.is_object() || !param.contains("name") || !param["name"].is_string() || + !param.contains("in") || !param["in"].is_string()) + continue; + + const std::string param_name = param["name"].get(); + const std::string location = param["in"].get(); + if (location != "path" && location != "query") + continue; + + Json schema = Json{{"type", "string"}}; + if (param.contains("schema") && param["schema"].is_object()) + schema = param["schema"]; + if (param.contains("description") && param["description"].is_string() && + (!schema.contains("description") || !schema["description"].is_string())) + schema["description"] = param["description"]; + + ParsedParameter parsed_param{ + param_name, + location, + schema, + param.value("required", false), + }; + + const std::string key = location + ":" + param_name; + auto existing = parameter_indices.find(key); + if (existing == parameter_indices.end()) + { + parameter_indices[key] = parsed_parameters.size(); + parameter_order.push_back(key); + parsed_parameters.push_back(std::move(parsed_param)); + } + else + { + parsed_parameters[existing->second] = std::move(parsed_param); + } + } + }; + + consume_parameters(path_params); + if (op.contains("parameters")) + consume_parameters(op["parameters"]); + + std::unordered_set required_names; + for (const auto& key : parameter_order) + { + const auto& parsed_param = parsed_parameters[parameter_indices[key]]; + properties[parsed_param.name] = parsed_param.schema; + + if (parsed_param.required && required_names.insert(parsed_param.name).second) + required.push_back(parsed_param.name); + + if (parsed_param.location == "path") + route.path_params.push_back(parsed_param.name); + else + route.query_params.push_back(parsed_param.name); + } + + if (op.contains("requestBody") && op["requestBody"].is_object()) + { + const auto& request_body = op["requestBody"]; + if (request_body.contains("content") && request_body["content"].is_object()) + { + const auto& content = request_body["content"]; + if (content.contains("application/json") && + content["application/json"].is_object() && + content["application/json"].contains("schema") && + content["application/json"]["schema"].is_object()) + { + properties["body"] = content["application/json"]["schema"]; + route.has_json_body = true; + if (request_body.value("required", false)) + required.push_back("body"); + } + } + } + + route.input_schema = Json{ + {"type", "object"}, + {"properties", properties}, + {"required", required}, + }; + + route.output_schema = Json::object(); + if (op.contains("responses") && op["responses"].is_object()) + { + for (const auto& key : {"200", "201", "202", "default"}) + { + if (!op["responses"].contains(key) || !op["responses"][key].is_object()) + continue; + const auto& response = op["responses"][key]; + if (!response.contains("content") || !response["content"].is_object()) + continue; + const auto& content = response["content"]; + if (content.contains("application/json") && + content["application/json"].is_object() && + content["application/json"].contains("schema") && + content["application/json"]["schema"].is_object()) + { + route.output_schema = content["application/json"]["schema"]; + break; + } + } + } + + if (!options_.validate_output && !route.output_schema.is_null()) + route.output_schema = + Json{{"type", "object"}, {"additionalProperties", true}}; + + routes.push_back(std::move(route)); + } + } + + return routes; +} + +Json OpenAPIProvider::invoke_route(const RouteDefinition& route, const Json& arguments) const +{ + const auto parsed = parse_base_url(base_url_); + + std::string resolved_path = route.path; + for (const auto& param : route.path_params) + { + if (!arguments.contains(param)) + throw ValidationError("Missing required path parameter: " + param); + const std::string placeholder = "{" + param + "}"; + const auto value = url_encode_component(to_string_value(arguments.at(param))); + size_t pos = std::string::npos; + while ((pos = resolved_path.find(placeholder)) != std::string::npos) + resolved_path.replace(pos, placeholder.size(), value); + } + + std::ostringstream query; + bool first = true; + for (const auto& param : route.query_params) + { + if (!arguments.contains(param)) + continue; + query << (first ? "?" : "&"); + first = false; + query << url_encode_component(param) << "=" + << url_encode_component(to_string_value(arguments.at(param))); + } + + std::string target = parsed.base_path + resolved_path + query.str(); + if (target.empty() || target.front() != '/') + target = "/" + target; + + std::string body; + if (route.has_json_body && arguments.contains("body")) + body = arguments["body"].dump(); + + std::unique_ptr client; + if (parsed.scheme == "http") + { + client = std::make_unique(parsed.host, parsed.port); + } + else + { +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT + client = std::make_unique(parsed.host, parsed.port); +#else + throw ValidationError( + "OpenAPIProvider https:// requires CPPHTTPLIB_OPENSSL_SUPPORT at build time"); +#endif + } + client->set_follow_location(true); + client->set_connection_timeout(30, 0); + client->set_read_timeout(30, 0); + + httplib::Result response; + const auto& m = route.method; + if (m == "GET") + response = client->Get(target.c_str()); + else if (m == "POST") + response = client->Post(target.c_str(), body, "application/json"); + else if (m == "PUT") + response = client->Put(target.c_str(), body, "application/json"); + else if (m == "PATCH") + response = client->Patch(target.c_str(), body, "application/json"); + else if (m == "DELETE") + response = client->Delete(target.c_str(), body, "application/json"); + else + throw ValidationError("Unsupported OpenAPI HTTP method: " + route.method); + + if (!response) + throw TransportError("OpenAPI HTTP request failed for " + route.method + " " + target); + + if (response->status >= 400) + throw std::runtime_error("OpenAPI route returned HTTP " + std::to_string(response->status)); + + if (response->body.empty()) + return Json::object(); + + try + { + return Json::parse(response->body); + } + catch (...) + { + return Json{{"status", response->status}, {"text", response->body}}; + } +} + +std::vector OpenAPIProvider::list_tools() const +{ + return tools_; +} + +std::optional OpenAPIProvider::get_tool(const std::string& name) const +{ + for (const auto& tool : tools_) + if (tool.name() == name) + return tool; + return std::nullopt; +} + +} // namespace fastmcpp::providers diff --git a/src/providers/skills_provider.cpp b/src/providers/skills_provider.cpp new file mode 100644 index 0000000..195480b --- /dev/null +++ b/src/providers/skills_provider.cpp @@ -0,0 +1,592 @@ +#include "fastmcpp/providers/skills_provider.hpp" + +#include "fastmcpp/exceptions.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastmcpp::providers +{ + +namespace +{ +std::string to_uri_path(const std::filesystem::path& path) +{ + auto text = path.generic_string(); + if (!text.empty() && text.front() == '/') + text.erase(text.begin()); + return text; +} + +bool is_text_extension(const std::filesystem::path& path) +{ + const auto ext = path.extension().string(); + static const std::unordered_set kTextExt = { + ".txt", ".md", ".markdown", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", + ".conf", ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".py", + ".cpp", ".hpp", ".c", ".h", ".rs", ".go", ".java", ".sh", ".ps1", + ".sql", ".log", + }; + return kTextExt.find(ext) != kTextExt.end(); +} + +std::optional detect_mime_type(const std::filesystem::path& path) +{ + const auto ext = path.extension().string(); + if (ext == ".md" || ext == ".markdown") + return "text/markdown"; + if (ext == ".json") + return "application/json"; + if (ext == ".yaml" || ext == ".yml") + return "application/yaml"; + if (is_text_extension(path)) + return "text/plain"; + return "application/octet-stream"; +} + +std::string compute_file_hash(const std::filesystem::path& path) +{ + std::ifstream in(path, std::ios::binary); + if (!in) + return "sha256:"; + + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + + auto rotr = [](uint32_t x, uint32_t n) -> uint32_t { return (x >> n) | (x << (32 - n)); }; + auto ch = [](uint32_t x, uint32_t y, uint32_t z) -> uint32_t { return (x & y) ^ (~x & z); }; + auto maj = [](uint32_t x, uint32_t y, uint32_t z) -> uint32_t + { return (x & y) ^ (x & z) ^ (y & z); }; + auto bsig0 = [&](uint32_t x) -> uint32_t { return rotr(x, 2) ^ rotr(x, 13) ^ rotr(x, 22); }; + auto bsig1 = [&](uint32_t x) -> uint32_t { return rotr(x, 6) ^ rotr(x, 11) ^ rotr(x, 25); }; + auto ssig0 = [&](uint32_t x) -> uint32_t { return rotr(x, 7) ^ rotr(x, 18) ^ (x >> 3); }; + auto ssig1 = [&](uint32_t x) -> uint32_t { return rotr(x, 17) ^ rotr(x, 19) ^ (x >> 10); }; + + static constexpr std::array k = { + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, 0x59f111f1U, + 0x923f82a4U, 0xab1c5ed5U, 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, + 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, 0xc19bf174U, 0xe49b69c1U, 0xefbe4786U, + 0x0fc19dc6U, 0x240ca1ccU, 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, + 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, + 0x06ca6351U, 0x14292967U, 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, 0x53380d13U, + 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, 0xa2bfe8a1U, 0xa81a664bU, + 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, + 0x19a4c116U, 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, + 0x5b9cca4fU, 0x682e6ff3U, 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, + 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U, + }; + + uint64_t bit_len = static_cast(bytes.size()) * 8ULL; + bytes.push_back(0x80U); + while ((bytes.size() % 64) != 56) + bytes.push_back(0x00U); + for (int i = 7; i >= 0; --i) + bytes.push_back(static_cast((bit_len >> (i * 8)) & 0xFFU)); + + uint32_t h0 = 0x6a09e667U; + uint32_t h1 = 0xbb67ae85U; + uint32_t h2 = 0x3c6ef372U; + uint32_t h3 = 0xa54ff53aU; + uint32_t h4 = 0x510e527fU; + uint32_t h5 = 0x9b05688cU; + uint32_t h6 = 0x1f83d9abU; + uint32_t h7 = 0x5be0cd19U; + + for (size_t offset = 0; offset < bytes.size(); offset += 64) + { + std::array w{}; + for (size_t i = 0; i < 16; ++i) + { + const size_t j = offset + i * 4; + w[i] = (static_cast(bytes[j]) << 24) | + (static_cast(bytes[j + 1]) << 16) | + (static_cast(bytes[j + 2]) << 8) | + static_cast(bytes[j + 3]); + } + for (size_t i = 16; i < 64; ++i) + w[i] = ssig1(w[i - 2]) + w[i - 7] + ssig0(w[i - 15]) + w[i - 16]; + + uint32_t a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7; + for (size_t i = 0; i < 64; ++i) + { + uint32_t t1 = h + bsig1(e) + ch(e, f, g) + k[i] + w[i]; + uint32_t t2 = bsig0(a) + maj(a, b, c); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + + h0 += a; + h1 += b; + h2 += c; + h3 += d; + h4 += e; + h5 += f; + h6 += g; + h7 += h; + } + + std::ostringstream out; + out << "sha256:" << std::hex << std::setfill('0') << std::nouppercase << std::setw(8) << h0 + << std::setw(8) << h1 << std::setw(8) << h2 << std::setw(8) << h3 << std::setw(8) << h4 + << std::setw(8) << h5 << std::setw(8) << h6 << std::setw(8) << h7; + return out.str(); +} + +std::string trim_copy(std::string value) +{ + value.erase(value.begin(), + std::find_if(value.begin(), value.end(), + [](unsigned char ch) { return !std::isspace(ch); })); + value.erase(std::find_if(value.rbegin(), value.rend(), + [](unsigned char ch) { return !std::isspace(ch); }) + .base(), + value.end()); + return value; +} + +std::optional parse_frontmatter_description(const std::filesystem::path& path) +{ + std::ifstream in(path, std::ios::binary); + if (!in) + return std::nullopt; + + std::string line; + if (!std::getline(in, line)) + return std::nullopt; + if (trim_copy(line) != "---") + return std::nullopt; + + while (std::getline(in, line)) + { + auto trimmed = trim_copy(line); + if (trimmed == "---") + break; + if (trimmed.rfind("description:", 0) != 0) + continue; + + auto value = trim_copy(trimmed.substr(std::string("description:").size())); + if (value.size() >= 2 && ((value.front() == '"' && value.back() == '"') || + (value.front() == '\'' && value.back() == '\''))) + value = value.substr(1, value.size() - 2); + if (!value.empty()) + return value; + } + return std::nullopt; +} + +bool is_within(const std::filesystem::path& root, const std::filesystem::path& candidate) +{ + const auto root_text = root.generic_string(); + const auto candidate_text = candidate.generic_string(); + if (candidate_text.size() < root_text.size()) + return false; + if (candidate_text.compare(0, root_text.size(), root_text) != 0) + return false; + return candidate_text.size() == root_text.size() || candidate_text[root_text.size()] == '/'; +} + +resources::ResourceContent read_file_content(const std::filesystem::path& path, const std::string& uri) +{ + if (!std::filesystem::exists(path) || !std::filesystem::is_regular_file(path)) + throw NotFoundError("Skill file not found: " + path.string()); + + resources::ResourceContent content; + content.uri = uri; + content.mime_type = detect_mime_type(path); + + if (is_text_extension(path)) + { + std::ifstream in(path, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + content.data = ss.str(); + return content; + } + + std::ifstream in(path, std::ios::binary); + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + content.data = std::move(bytes); + return content; +} + +std::filesystem::path home_dir() +{ +#ifdef _WIN32 + const char* profile = std::getenv("USERPROFILE"); + if (profile && *profile) + return std::filesystem::path(profile); + const char* drive = std::getenv("HOMEDRIVE"); + const char* home = std::getenv("HOMEPATH"); + if (drive && home) + return std::filesystem::path(std::string(drive) + std::string(home)); +#else + const char* home = std::getenv("HOME"); + if (home && *home) + return std::filesystem::path(home); +#endif + return std::filesystem::current_path(); +} +} // namespace + +SkillProvider::SkillProvider(std::filesystem::path skill_path, std::string main_file_name, + SkillSupportingFiles supporting_files) + : skill_path_(std::filesystem::absolute(std::move(skill_path)).lexically_normal()), + skill_name_(skill_path_.filename().string()), main_file_name_(std::move(main_file_name)), + supporting_files_(supporting_files) +{ + if (!std::filesystem::exists(skill_path_) || !std::filesystem::is_directory(skill_path_)) + throw ValidationError("Skill directory not found: " + skill_path_.string()); + + const auto main_file = skill_path_ / main_file_name_; + if (!std::filesystem::exists(main_file)) + throw ValidationError("Main skill file not found: " + main_file.string()); +} + +std::vector SkillProvider::list_files() const +{ + std::vector files; + for (const auto& entry : std::filesystem::recursive_directory_iterator(skill_path_)) + { + if (entry.is_regular_file()) + files.push_back(entry.path()); + } + return files; +} + +std::string SkillProvider::build_description() const +{ + const auto main_path = skill_path_ / main_file_name_; + if (auto frontmatter_description = parse_frontmatter_description(main_path)) + return *frontmatter_description; + + std::ifstream in(main_path, std::ios::binary); + if (!in) + return "Skill: " + skill_name_; + + std::string line; + while (std::getline(in, line)) + { + auto trimmed = trim_copy(line); + if (trimmed.empty()) + continue; + if (trimmed[0] == '#') + { + size_t i = 0; + while (i < trimmed.size() && trimmed[i] == '#') + ++i; + if (i < trimmed.size() && trimmed[i] == ' ') + ++i; + trimmed = trimmed.substr(i); + } + if (!trimmed.empty()) + return trimmed.substr(0, 200); + } + + return "Skill: " + skill_name_; +} + +std::string SkillProvider::build_manifest_json() const +{ + Json files = Json::array(); + for (const auto& file : list_files()) + { + const auto rel = std::filesystem::relative(file, skill_path_); + files.push_back(Json{ + {"path", to_uri_path(rel)}, + {"size", static_cast(std::filesystem::file_size(file))}, + {"hash", compute_file_hash(file)}, + }); + } + return Json{{"skill", skill_name_}, {"files", files}}.dump(2); +} + +std::vector SkillProvider::list_resources() const +{ + std::vector out; + const auto description = build_description(); + + resources::Resource main_file; + main_file.uri = "skill://" + skill_name_ + "/" + main_file_name_; + main_file.name = skill_name_ + "/" + main_file_name_; + main_file.description = description; + main_file.mime_type = "text/markdown"; + main_file.provider = [main_path = skill_path_ / main_file_name_, uri = main_file.uri](const Json&) + { return read_file_content(main_path, uri); }; + out.push_back(main_file); + + resources::Resource manifest; + manifest.uri = "skill://" + skill_name_ + "/_manifest"; + manifest.name = skill_name_ + "/_manifest"; + manifest.description = "File listing for " + skill_name_; + manifest.mime_type = "application/json"; + manifest.provider = [this, uri = manifest.uri](const Json&) + { + resources::ResourceContent content; + content.uri = uri; + content.mime_type = "application/json"; + content.data = build_manifest_json(); + return content; + }; + out.push_back(manifest); + + if (supporting_files_ == SkillSupportingFiles::Resources) + { + for (const auto& file : list_files()) + { + const auto rel = std::filesystem::relative(file, skill_path_); + if (to_uri_path(rel) == main_file_name_) + continue; + + resources::Resource resource; + resource.uri = "skill://" + skill_name_ + "/" + to_uri_path(rel); + resource.name = skill_name_ + "/" + to_uri_path(rel); + resource.description = "File from " + skill_name_ + " skill"; + resource.mime_type = detect_mime_type(file); + resource.provider = [file, uri = resource.uri](const Json&) + { return read_file_content(file, uri); }; + out.push_back(std::move(resource)); + } + } + + return out; +} + +std::optional SkillProvider::get_resource(const std::string& uri) const +{ + for (const auto& resource : list_resources()) + if (resource.uri == uri) + return resource; + return std::nullopt; +} + +std::vector SkillProvider::list_resource_templates() const +{ + if (supporting_files_ != SkillSupportingFiles::Template) + return {}; + + resources::ResourceTemplate templ; + templ.uri_template = "skill://" + skill_name_ + "/{path*}"; + templ.name = skill_name_ + "_files"; + templ.description = "Access files within " + skill_name_; + templ.mime_type = "application/octet-stream"; + templ.parameters = Json{{"type", "object"}, + {"properties", Json{{"path", Json{{"type", "string"}}}}}, + {"required", Json::array({"path"})}}; + templ.provider = [root = skill_path_, skill_name = skill_name_](const Json& params) + { + const std::string rel = params.value("path", ""); + if (rel.empty()) + throw ValidationError("Missing path parameter"); + + const auto full = std::filesystem::weakly_canonical(root / rel); + if (!is_within(root, full)) + throw ValidationError("Skill path escapes root: " + rel); + + const std::string uri = "skill://" + skill_name + "/" + to_uri_path(std::filesystem::relative(full, root)); + return read_file_content(full, uri); + }; + templ.parse(); + return {templ}; +} + +std::optional +SkillProvider::get_resource_template(const std::string& uri) const +{ + for (const auto& templ : list_resource_templates()) + if (templ.match(uri)) + return templ; + return std::nullopt; +} + +SkillsDirectoryProvider::SkillsDirectoryProvider(std::vector roots, bool reload, + std::string main_file_name, + SkillSupportingFiles supporting_files) + : roots_(std::move(roots)), reload_(reload), main_file_name_(std::move(main_file_name)), + supporting_files_(supporting_files) +{ + discover_skills(); +} + +SkillsDirectoryProvider::SkillsDirectoryProvider(std::filesystem::path root, bool reload, + std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(std::vector{std::move(root)}, reload, + std::move(main_file_name), supporting_files) +{ +} + +void SkillsDirectoryProvider::ensure_discovered() const +{ + if (!discovered_ || reload_) + discover_skills(); +} + +void SkillsDirectoryProvider::discover_skills() const +{ + providers_.clear(); + std::unordered_set seen_names; + + for (const auto& root_raw : roots_) + { + const auto root = std::filesystem::absolute(root_raw).lexically_normal(); + if (!std::filesystem::exists(root) || !std::filesystem::is_directory(root)) + continue; + + for (const auto& entry : std::filesystem::directory_iterator(root)) + { + if (!entry.is_directory()) + continue; + + const auto skill_dir = entry.path(); + const auto main_file = skill_dir / main_file_name_; + if (!std::filesystem::exists(main_file)) + continue; + + const auto skill_name = skill_dir.filename().string(); + if (!seen_names.insert(skill_name).second) + continue; + + try + { + providers_.push_back(std::make_shared( + skill_dir, main_file_name_, supporting_files_)); + } + catch (...) + { + // Skip unreadable/invalid skills. + } + } + } + + discovered_ = true; +} + +std::vector SkillsDirectoryProvider::list_resources() const +{ + ensure_discovered(); + std::vector out; + std::unordered_set seen; + for (const auto& provider : providers_) + { + for (const auto& resource : provider->list_resources()) + { + if (seen.insert(resource.uri).second) + out.push_back(resource); + } + } + return out; +} + +std::optional +SkillsDirectoryProvider::get_resource(const std::string& uri) const +{ + ensure_discovered(); + for (const auto& provider : providers_) + { + auto resource = provider->get_resource(uri); + if (resource) + return resource; + } + return std::nullopt; +} + +std::vector SkillsDirectoryProvider::list_resource_templates() const +{ + ensure_discovered(); + std::vector out; + std::unordered_set seen; + for (const auto& provider : providers_) + { + for (const auto& templ : provider->list_resource_templates()) + { + if (seen.insert(templ.uri_template).second) + out.push_back(templ); + } + } + return out; +} + +std::optional +SkillsDirectoryProvider::get_resource_template(const std::string& uri) const +{ + ensure_discovered(); + for (const auto& provider : providers_) + { + auto templ = provider->get_resource_template(uri); + if (templ) + return templ; + } + return std::nullopt; +} + +ClaudeSkillsProvider::ClaudeSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".claude" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +CursorSkillsProvider::CursorSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".cursor" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +VSCodeSkillsProvider::VSCodeSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".copilot" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +CodexSkillsProvider::CodexSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider( + std::vector{std::filesystem::path("/etc/codex/skills"), + home_dir() / ".codex" / "skills"}, + reload, std::move(main_file_name), supporting_files) +{ +} + +GeminiSkillsProvider::GeminiSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".gemini" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +GooseSkillsProvider::GooseSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".config" / "agents" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +CopilotSkillsProvider::CopilotSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".copilot" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +OpenCodeSkillsProvider::OpenCodeSkillsProvider(bool reload, std::string main_file_name, + SkillSupportingFiles supporting_files) + : SkillsDirectoryProvider(home_dir() / ".config" / "opencode" / "skills", reload, + std::move(main_file_name), supporting_files) +{ +} + +} // namespace fastmcpp::providers diff --git a/src/providers/transforms/prompts_as_tools.cpp b/src/providers/transforms/prompts_as_tools.cpp new file mode 100644 index 0000000..2215f59 --- /dev/null +++ b/src/providers/transforms/prompts_as_tools.cpp @@ -0,0 +1,104 @@ +#include "fastmcpp/providers/transforms/prompts_as_tools.hpp" + +#include "fastmcpp/providers/provider.hpp" + +namespace fastmcpp::providers::transforms +{ + +tools::Tool PromptsAsTools::make_list_prompts_tool() const +{ + auto provider = provider_; + tools::Tool::Fn fn = [provider](const Json& /*args*/) -> Json + { + if (!provider) + return Json{{"error", "Provider not set"}}; + auto prompts = provider->list_prompts(); + Json result = Json::array(); + for (const auto& p : prompts) + { + Json entry = {{"name", p.name}}; + if (p.description) + entry["description"] = *p.description; + if (!p.arguments.empty()) + { + Json args = Json::array(); + for (const auto& a : p.arguments) + { + Json arg = {{"name", a.name}, {"required", a.required}}; + if (a.description) + arg["description"] = *a.description; + args.push_back(arg); + } + entry["arguments"] = args; + } + result.push_back(entry); + } + return Json{{"type", "text"}, {"text", result.dump(2)}}; + }; + + return tools::Tool("list_prompts", Json::object(), Json(), fn, std::nullopt, + std::optional("List available prompts and their arguments"), + std::nullopt); +} + +tools::Tool PromptsAsTools::make_get_prompt_tool() const +{ + auto provider = provider_; + tools::Tool::Fn fn = [provider](const Json& args) -> Json + { + if (!provider) + return Json{{"error", "Provider not set"}}; + std::string name = args.value("name", ""); + if (name.empty()) + return Json{{"error", "Missing prompt name"}}; + auto prompt_opt = provider->get_prompt(name); + if (!prompt_opt) + return Json{{"error", "Prompt not found: " + name}}; + + Json arguments = args.value("arguments", Json::object()); + std::unordered_map vars; + if (arguments.is_object()) + { + for (auto it = arguments.begin(); it != arguments.end(); ++it) + { + if (it.value().is_string()) + vars[it.key()] = it.value().get(); + else + vars[it.key()] = it.value().dump(); + } + } + + std::string rendered = prompt_opt->render(vars); + return Json{{"type", "text"}, {"text", rendered}}; + }; + + Json schema = { + {"type", "object"}, + {"properties", Json{{"name", Json{{"type", "string"}}}, + {"arguments", Json{{"type", "object"}}}}}, + {"required", Json::array({"name"})}}; + + return tools::Tool("get_prompt", schema, Json(), fn, std::nullopt, + std::optional("Get a rendered prompt by name"), + std::nullopt); +} + +std::vector PromptsAsTools::list_tools(const ListToolsNext& call_next) const +{ + auto tools = call_next(); + tools.push_back(make_list_prompts_tool()); + tools.push_back(make_get_prompt_tool()); + return tools; +} + +std::optional PromptsAsTools::get_tool(const std::string& name, + const GetToolNext& call_next) const +{ + if (name == "list_prompts") + return make_list_prompts_tool(); + if (name == "get_prompt") + return make_get_prompt_tool(); + return call_next(name); +} + +} // namespace fastmcpp::providers::transforms diff --git a/src/providers/transforms/resources_as_tools.cpp b/src/providers/transforms/resources_as_tools.cpp new file mode 100644 index 0000000..1ef99c9 --- /dev/null +++ b/src/providers/transforms/resources_as_tools.cpp @@ -0,0 +1,91 @@ +#include "fastmcpp/providers/transforms/resources_as_tools.hpp" + +#include "fastmcpp/providers/provider.hpp" + +namespace fastmcpp::providers::transforms +{ + +tools::Tool ResourcesAsTools::make_list_resources_tool() const +{ + auto provider = provider_; + tools::Tool::Fn fn = [provider](const Json& /*args*/) -> Json + { + if (!provider) + return Json{{"error", "Provider not set"}}; + + Json result = Json::array(); + for (const auto& r : provider->list_resources()) + { + Json entry = {{"uri", r.uri}, {"name", r.name}}; + if (r.description) + entry["description"] = *r.description; + if (r.mime_type) + entry["mimeType"] = *r.mime_type; + result.push_back(entry); + } + + for (const auto& t : provider->list_resource_templates()) + { + Json entry = {{"uriTemplate", t.uri_template}, {"name", t.name}}; + if (t.description) + entry["description"] = *t.description; + result.push_back(entry); + } + + return Json{{"type", "text"}, {"text", result.dump(2)}}; + }; + + return tools::Tool("list_resources", Json::object(), Json(), fn, std::nullopt, + std::optional("List available resources and resource templates"), + std::nullopt); +} + +tools::Tool ResourcesAsTools::make_read_resource_tool() const +{ + auto reader = resource_reader_; + tools::Tool::Fn fn = [reader](const Json& args) -> Json + { + std::string uri = args.value("uri", ""); + if (uri.empty()) + return Json{{"error", "Missing resource URI"}}; + if (!reader) + return Json{{"error", "Resource reader not configured"}}; + + auto content = reader(uri, Json::object()); + if (auto* text = std::get_if(&content.data)) + return Json{{"type", "text"}, {"text", *text}}; + if (std::get_if>(&content.data)) + return Json{{"type", "text"}, + {"text", std::string("[binary data: ") + + content.mime_type.value_or("application/octet-stream") + "]"}}; + return Json{{"type", "text"}, {"text", ""}}; + }; + + Json schema = {{"type", "object"}, + {"properties", Json{{"uri", Json{{"type", "string"}}}}}, + {"required", Json::array({"uri"})}}; + + return tools::Tool("read_resource", schema, Json(), fn, std::nullopt, + std::optional("Read a resource by URI"), + std::nullopt); +} + +std::vector ResourcesAsTools::list_tools(const ListToolsNext& call_next) const +{ + auto tools = call_next(); + tools.push_back(make_list_resources_tool()); + tools.push_back(make_read_resource_tool()); + return tools; +} + +std::optional ResourcesAsTools::get_tool(const std::string& name, + const GetToolNext& call_next) const +{ + if (name == "list_resources") + return make_list_resources_tool(); + if (name == "read_resource") + return make_read_resource_tool(); + return call_next(name); +} + +} // namespace fastmcpp::providers::transforms diff --git a/src/providers/transforms/version_filter.cpp b/src/providers/transforms/version_filter.cpp new file mode 100644 index 0000000..aaf089e --- /dev/null +++ b/src/providers/transforms/version_filter.cpp @@ -0,0 +1,182 @@ +#include "fastmcpp/providers/transforms/version_filter.hpp" + +#include +#include +#include + +namespace fastmcpp::providers::transforms +{ + +namespace +{ +bool is_digits(const std::string& s) +{ + return !s.empty() && + std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); +} + +std::string strip_leading_zeros(const std::string& s) +{ + size_t i = 0; + while (i + 1 < s.size() && s[i] == '0') + ++i; + return s.substr(i); +} + +std::vector split_version(const std::string& version) +{ + std::vector parts; + std::string current; + for (char c : version) + { + if (c == '.' || c == '-' || c == '_') + { + if (!current.empty()) + { + parts.push_back(current); + current.clear(); + } + continue; + } + current.push_back(c); + } + if (!current.empty()) + parts.push_back(current); + return parts; +} + +int compare_token(const std::string& a, const std::string& b) +{ + if (a == b) + return 0; + + if (is_digits(a) && is_digits(b)) + { + const auto a_norm = strip_leading_zeros(a); + const auto b_norm = strip_leading_zeros(b); + if (a_norm.size() != b_norm.size()) + return a_norm.size() < b_norm.size() ? -1 : 1; + return a_norm < b_norm ? -1 : 1; + } + + return a < b ? -1 : 1; +} + +int compare_versions(const std::string& a, const std::string& b) +{ + const auto a_parts = split_version(a); + const auto b_parts = split_version(b); + const size_t n = std::max(a_parts.size(), b_parts.size()); + for (size_t i = 0; i < n; ++i) + { + const std::string& a_tok = i < a_parts.size() ? a_parts[i] : std::string("0"); + const std::string& b_tok = i < b_parts.size() ? b_parts[i] : std::string("0"); + int cmp = compare_token(a_tok, b_tok); + if (cmp != 0) + return cmp; + } + return 0; +} +} // namespace + +VersionFilter::VersionFilter(std::optional version_gte, + std::optional version_lt) + : version_gte_(std::move(version_gte)), version_lt_(std::move(version_lt)) +{ + if (!version_gte_ && !version_lt_) + throw ValidationError("At least one of version_gte/version_lt must be set"); +} + +VersionFilter::VersionFilter(std::string version_gte) + : version_gte_(std::move(version_gte)) +{ +} + +bool VersionFilter::matches(const std::optional& version) const +{ + // Python fastmcp intentionally lets unversioned components pass any range filter. + if (!version) + return true; + if (version_gte_ && compare_versions(*version, *version_gte_) < 0) + return false; + if (version_lt_ && compare_versions(*version, *version_lt_) >= 0) + return false; + return true; +} + +std::vector VersionFilter::list_tools(const ListToolsNext& call_next) const +{ + std::vector filtered; + for (const auto& tool : call_next()) + if (matches(tool.version())) + filtered.push_back(tool); + return filtered; +} + +std::optional VersionFilter::get_tool(const std::string& name, + const GetToolNext& call_next) const +{ + auto tool = call_next(name); + if (!tool || !matches(tool->version())) + return std::nullopt; + return tool; +} + +std::vector +VersionFilter::list_resources(const ListResourcesNext& call_next) const +{ + std::vector filtered; + for (const auto& resource : call_next()) + if (matches(resource.version)) + filtered.push_back(resource); + return filtered; +} + +std::optional +VersionFilter::get_resource(const std::string& uri, const GetResourceNext& call_next) const +{ + auto resource = call_next(uri); + if (!resource || !matches(resource->version)) + return std::nullopt; + return resource; +} + +std::vector +VersionFilter::list_resource_templates(const ListResourceTemplatesNext& call_next) const +{ + std::vector filtered; + for (const auto& templ : call_next()) + if (matches(templ.version)) + filtered.push_back(templ); + return filtered; +} + +std::optional +VersionFilter::get_resource_template(const std::string& uri, + const GetResourceTemplateNext& call_next) const +{ + auto templ = call_next(uri); + if (!templ || !matches(templ->version)) + return std::nullopt; + return templ; +} + +std::vector VersionFilter::list_prompts(const ListPromptsNext& call_next) const +{ + std::vector filtered; + for (const auto& prompt : call_next()) + if (matches(prompt.version)) + filtered.push_back(prompt); + return filtered; +} + +std::optional VersionFilter::get_prompt(const std::string& name, + const GetPromptNext& call_next) const +{ + auto prompt = call_next(name); + if (!prompt || !matches(prompt->version)) + return std::nullopt; + return prompt; +} + +} // namespace fastmcpp::providers::transforms diff --git a/src/proxy.cpp b/src/proxy.cpp index c46d124..fffdc4a 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -30,6 +30,8 @@ client::ToolInfo ProxyApp::tool_to_info(const tools::Tool& tool) info.execution = fastmcpp::Json{{"taskSupport", to_string(tool.task_support())}}; info.title = tool.title(); info.icons = tool.icons(); + if (tool.app() && !tool.app()->empty()) + info.app = *tool.app(); return info; } @@ -43,6 +45,8 @@ client::ResourceInfo ProxyApp::resource_to_info(const resources::Resource& res) info.title = res.title; info.annotations = res.annotations; info.icons = res.icons; + if (res.app && !res.app->empty()) + info.app = *res.app; return info; } @@ -56,6 +60,8 @@ client::ResourceTemplate ProxyApp::template_to_info(const resources::ResourceTem info.title = templ.title; info.annotations = templ.annotations; info.icons = templ.icons; + if (templ.app && !templ.app->empty()) + info.app = *templ.app; return info; } @@ -374,8 +380,7 @@ namespace { bool is_supported_url_scheme(const std::string& url) { - return url.rfind("ws://", 0) == 0 || url.rfind("wss://", 0) == 0 || - url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0; + return url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0; } // Helper to create client factory from URL @@ -384,20 +389,13 @@ ProxyApp::ClientFactory make_url_factory(std::string url) return [url = std::move(url)]() -> client::Client { // Detect transport type from URL - if (url.find("ws://") == 0 || url.find("wss://") == 0) - { - return client::Client(std::make_unique(url)); - } - else if (url.find("http://") == 0 || url.find("https://") == 0) + if (url.find("http://") == 0 || url.find("https://") == 0) { // Default to HTTP transport for regular HTTP URLs // For SSE, user should create HttpSseTransport explicitly return client::Client(std::make_unique(url)); } - else - { - throw std::invalid_argument("Unsupported URL scheme: " + url); - } + throw std::invalid_argument("Unsupported URL scheme: " + url); }; } } // anonymous namespace diff --git a/src/resources/template.cpp b/src/resources/template.cpp index a7cf6b9..7002116 100644 --- a/src/resources/template.cpp +++ b/src/resources/template.cpp @@ -311,6 +311,7 @@ ResourceTemplate::create_resource(const std::string& uri, resource.name = name; resource.description = description; resource.mime_type = mime_type; + resource.app = app; // Create a provider that captures the extracted params and delegates to the template provider if (provider) diff --git a/src/server/context.cpp b/src/server/context.cpp index 5a18e23..311f916 100644 --- a/src/server/context.cpp +++ b/src/server/context.cpp @@ -17,10 +17,11 @@ Context::Context(const resources::ResourceManager& rm, const prompts::PromptMana Context::Context(const resources::ResourceManager& rm, const prompts::PromptManager& pm, std::optional request_meta, std::optional request_id, - std::optional session_id, std::optional transport) + std::optional session_id, std::optional transport, + SessionStatePtr session_state) : resource_mgr_(&rm), prompt_mgr_(&pm), request_meta_(std::move(request_meta)), request_id_(std::move(request_id)), session_id_(std::move(session_id)), - transport_(std::move(transport)) + transport_(std::move(transport)), session_state_(std::move(session_state)) { } diff --git a/src/server/ping_middleware.cpp b/src/server/ping_middleware.cpp new file mode 100644 index 0000000..842d8e2 --- /dev/null +++ b/src/server/ping_middleware.cpp @@ -0,0 +1,36 @@ +#include "fastmcpp/server/ping_middleware.hpp" + +#include +#include + +namespace fastmcpp::server +{ + +PingMiddleware::PingMiddleware(std::chrono::milliseconds interval) : interval_(interval) {} + +std::pair PingMiddleware::make_hooks() const +{ + auto interval = interval_; + + // Shared stop flag between before and after hooks + auto stop_flag = std::make_shared>(false); + + BeforeHook before = [stop_flag](const std::string& route, + const fastmcpp::Json& /*payload*/) -> std::optional + { + if (route == "tools/call") + stop_flag->store(false); + return std::nullopt; + }; + + AfterHook after = [stop_flag](const std::string& route, const fastmcpp::Json& /*payload*/, + fastmcpp::Json& /*response*/) + { + if (route == "tools/call") + stop_flag->store(true); + }; + + return {std::move(before), std::move(after)}; +} + +} // namespace fastmcpp::server diff --git a/src/server/response_limiting_middleware.cpp b/src/server/response_limiting_middleware.cpp new file mode 100644 index 0000000..d1877c2 --- /dev/null +++ b/src/server/response_limiting_middleware.cpp @@ -0,0 +1,73 @@ +#include "fastmcpp/server/response_limiting_middleware.hpp" + +#include + +namespace fastmcpp::server +{ + +ResponseLimitingMiddleware::ResponseLimitingMiddleware(size_t max_size, + std::string truncation_suffix, + std::vector tool_filter) + : max_size_(max_size), truncation_suffix_(std::move(truncation_suffix)), + tool_filter_(std::move(tool_filter)) +{ +} + +AfterHook ResponseLimitingMiddleware::make_hook() const +{ + auto max_size = max_size_; + auto suffix = truncation_suffix_; + auto filter = tool_filter_; + + return [max_size, suffix, filter](const std::string& route, const fastmcpp::Json& payload, + fastmcpp::Json& response) + { + if (route != "tools/call") + return; + + // Check tool filter + if (!filter.empty()) + { + std::string tool_name = payload.value("name", ""); + if (std::find(filter.begin(), filter.end(), tool_name) == filter.end()) + return; + } + + // AfterHook usually receives the route payload directly ({"content":[...]}), + // but some call sites pass a JSON-RPC envelope ({"result":{"content":[...]}}). + fastmcpp::Json* content = nullptr; + if (response.contains("content") && response["content"].is_array()) + content = &response["content"]; + else if (response.contains("result") && response["result"].is_object() && + response["result"].contains("content") && response["result"]["content"].is_array()) + content = &response["result"]["content"]; + if (!content) + return; + + // Concatenate all text content + std::string combined; + for (const auto& item : *content) + { + if (item.value("type", "") == "text") + combined += item.value("text", ""); + } + + if (combined.size() <= max_size) + return; + + // UTF-8 safe truncation: find a valid boundary + size_t cut = max_size; + if (cut > suffix.size()) + cut -= suffix.size(); + while (cut > 0 && (static_cast(combined[cut]) & 0xC0) == 0x80) + --cut; + + std::string truncated = combined.substr(0, cut) + suffix; + + // Replace content with single truncated text entry + *content = fastmcpp::Json::array(); + content->push_back(fastmcpp::Json{{"type", "text"}, {"text", truncated}}); + }; +} + +} // namespace fastmcpp::server diff --git a/src/util/json_schema.cpp b/src/util/json_schema.cpp index 4126cac..7bec484 100644 --- a/src/util/json_schema.cpp +++ b/src/util/json_schema.cpp @@ -1,6 +1,10 @@ #include "fastmcpp/util/json_schema.hpp" -#include +#include +#include +#include +#include +#include namespace fastmcpp::util::schema { @@ -54,6 +58,100 @@ static void validate_object(const Json& schema, const Json& inst) } } +static bool contains_ref_impl(const Json& schema) +{ + if (schema.is_object()) + { + auto ref_it = schema.find("$ref"); + if (ref_it != schema.end() && ref_it->is_string()) + return true; + for (const auto& [_, value] : schema.items()) + if (contains_ref_impl(value)) + return true; + return false; + } + + if (schema.is_array()) + { + for (const auto& item : schema) + if (contains_ref_impl(item)) + return true; + } + return false; +} + +static std::optional resolve_local_ref(const Json& root, const std::string& ref) +{ + if (ref.empty() || ref[0] != '#') + return std::nullopt; + + std::string pointer = ref.substr(1); + if (pointer.empty()) + return root; + + try + { + nlohmann::json::json_pointer json_ptr(pointer); + return root.at(json_ptr); + } + catch (...) + { + return std::nullopt; + } +} + +static Json dereference_node(const Json& node, const Json& root, std::vector& stack) +{ + if (node.is_object()) + { + auto ref_it = node.find("$ref"); + if (ref_it != node.end() && ref_it->is_string()) + { + const std::string ref = ref_it->get(); + if (std::find(stack.begin(), stack.end(), ref) == stack.end()) + { + auto resolved = resolve_local_ref(root, ref); + if (resolved.has_value()) + { + stack.push_back(ref); + Json dereferenced = dereference_node(*resolved, root, stack); + stack.pop_back(); + + if (dereferenced.is_object()) + { + Json merged = dereferenced; + for (const auto& [key, value] : node.items()) + { + if (key == "$ref") + continue; + merged[key] = dereference_node(value, root, stack); + } + return merged; + } + + if (node.size() == 1) + return dereferenced; + } + } + } + + Json result = Json::object(); + for (const auto& [key, value] : node.items()) + result[key] = dereference_node(value, root, stack); + return result; + } + + if (node.is_array()) + { + Json result = Json::array(); + for (const auto& item : node) + result.push_back(dereference_node(item, root, stack)); + return result; + } + + return node; +} + void validate(const Json& schema, const Json& instance) { if (schema.contains("type")) @@ -66,4 +164,23 @@ void validate(const Json& schema, const Json& instance) } } +bool contains_ref(const Json& schema) +{ + return contains_ref_impl(schema); +} + +Json dereference_refs(const Json& schema) +{ + if (!schema.is_object() && !schema.is_array()) + return schema; + + std::vector stack; + Json dereferenced = dereference_node(schema, schema, stack); + + if (dereferenced.is_object() && dereferenced.contains("$defs") && !contains_ref_impl(dereferenced)) + dereferenced.erase("$defs"); + + return dereferenced; +} + } // namespace fastmcpp::util::schema diff --git a/tests/app/mcp_apps.cpp b/tests/app/mcp_apps.cpp new file mode 100644 index 0000000..8f0d059 --- /dev/null +++ b/tests/app/mcp_apps.cpp @@ -0,0 +1,311 @@ +/// @file mcp_apps.cpp +/// @brief Integration tests for MCP Apps metadata parity (_meta.ui) + +#include "fastmcpp/app.hpp" +#include "fastmcpp/client/client.hpp" +#include "fastmcpp/client/transports.hpp" +#include "fastmcpp/mcp/handler.hpp" + +#include +#include + +using namespace fastmcpp; + +#define CHECK_TRUE(cond, msg) \ + do \ + { \ + if (!(cond)) \ + { \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")\n"; \ + return 1; \ + } \ + } while (0) + +static Json request(int id, const std::string& method, Json params = Json::object()) +{ + return Json{{"jsonrpc", "2.0"}, {"id", id}, {"method", method}, {"params", params}}; +} + +static int test_tool_meta_ui_emitted_and_parsed() +{ + std::cout << "test_tool_meta_ui_emitted_and_parsed...\n"; + + FastMCP app("apps_tool_test", "1.0.0"); + FastMCP::ToolOptions opts; + + AppConfig tool_app; + tool_app.resource_uri = "ui://widgets/echo.html"; + tool_app.visibility = std::vector{"tool_result"}; + tool_app.domain = "https://example.test"; + opts.app = tool_app; + + app.tool("echo_tool", [](const Json& in) { return in; }, opts); + + auto handler = mcp::make_mcp_handler(app); + auto init = handler(request(1, "initialize")); + CHECK_TRUE(init.contains("result"), "initialize should return result"); + + auto list = handler(request(2, "tools/list")); + CHECK_TRUE(list.contains("result") && list["result"].contains("tools"), "tools/list missing tools"); + CHECK_TRUE(list["result"]["tools"].is_array() && list["result"]["tools"].size() == 1, + "tools/list should return one tool"); + + const auto& tool = list["result"]["tools"][0]; + CHECK_TRUE(tool.contains("_meta") && tool["_meta"].contains("ui"), "tool missing _meta.ui"); + CHECK_TRUE(tool["_meta"]["ui"].value("resourceUri", "") == "ui://widgets/echo.html", + "tool _meta.ui.resourceUri mismatch"); + + // Client parsing path: _meta.ui -> client::ToolInfo.app + client::Client c(std::make_unique(handler)); + c.call("initialize", + Json{{"protocolVersion", "2024-11-05"}, + {"capabilities", Json::object()}, + {"clientInfo", Json{{"name", "apps-test"}, {"version", "1.0.0"}}}}); + auto tools = c.list_tools(); + CHECK_TRUE(tools.size() == 1, "client list_tools should return one tool"); + CHECK_TRUE(tools[0].app.has_value(), "client tool should parse app metadata"); + CHECK_TRUE(tools[0].app->resource_uri.has_value(), "client tool app should include resource_uri"); + CHECK_TRUE(*tools[0].app->resource_uri == "ui://widgets/echo.html", + "client tool app resource_uri mismatch"); + + return 0; +} + +static int test_resource_template_ui_defaults_and_meta() +{ + std::cout << "test_resource_template_ui_defaults_and_meta...\n"; + + FastMCP app("apps_resource_test", "1.0.0"); + + FastMCP::ResourceOptions res_opts; + AppConfig res_app; + res_app.domain = "https://ui.example.test"; + res_app.prefers_border = true; + res_opts.app = res_app; + + app.resource("ui://widgets/home.html", "home", + [](const Json&) + { + return resources::ResourceContent{"ui://widgets/home.html", std::nullopt, + std::string{"home"}}; + }, + res_opts); + + FastMCP::ResourceTemplateOptions templ_opts; + AppConfig templ_app; + templ_app.csp = Json{{"connectDomains", Json::array({"https://api.example.test"})}}; + templ_opts.app = templ_app; + + app.resource_template("ui://widgets/{id}.html", "widget", + [](const Json& params) + { + std::string id = params.value("id", "unknown"); + return resources::ResourceContent{"ui://widgets/" + id + ".html", + std::nullopt, + std::string{"widget"}}; + }, + Json::object(), templ_opts); + + auto handler = mcp::make_mcp_handler(app); + handler(request(10, "initialize")); + + auto resources_list = handler(request(11, "resources/list")); + CHECK_TRUE(resources_list.contains("result") && resources_list["result"].contains("resources"), + "resources/list missing resources"); + CHECK_TRUE(resources_list["result"]["resources"].size() == 1, "expected one resource"); + + const auto& res = resources_list["result"]["resources"][0]; + CHECK_TRUE(res.value("mimeType", "") == "text/html;profile=mcp-app", + "ui:// resource should default mimeType"); + CHECK_TRUE(res.contains("_meta") && res["_meta"].contains("ui"), + "resource should include _meta.ui"); + CHECK_TRUE(res["_meta"]["ui"].value("domain", "") == "https://ui.example.test", + "resource _meta.ui.domain mismatch"); + + auto templates_list = handler(request(12, "resources/templates/list")); + CHECK_TRUE(templates_list.contains("result") && + templates_list["result"].contains("resourceTemplates"), + "resources/templates/list missing resourceTemplates"); + CHECK_TRUE(templates_list["result"]["resourceTemplates"].size() == 1, + "expected one resource template"); + + const auto& templ = templates_list["result"]["resourceTemplates"][0]; + CHECK_TRUE(templ.value("mimeType", "") == "text/html;profile=mcp-app", + "ui:// template should default mimeType"); + CHECK_TRUE(templ.contains("_meta") && templ["_meta"].contains("ui"), + "resource template should include _meta.ui"); + + auto read_result = + handler(request(13, "resources/read", Json{{"uri", "ui://widgets/home.html"}})); + CHECK_TRUE(read_result.contains("result") && read_result["result"].contains("contents"), + "resources/read missing contents"); + CHECK_TRUE(read_result["result"]["contents"].is_array() && + read_result["result"]["contents"].size() == 1, + "resources/read expected one content item"); + const auto& content = read_result["result"]["contents"][0]; + CHECK_TRUE(content.contains("_meta") && content["_meta"].contains("ui"), + "resources/read content should include _meta.ui"); + CHECK_TRUE(content["_meta"]["ui"].value("domain", "") == "https://ui.example.test", + "resources/read content _meta.ui.domain mismatch"); + + return 0; +} + +static int test_template_read_inherits_ui_meta() +{ + std::cout << "test_template_read_inherits_ui_meta...\n"; + + FastMCP app("apps_template_read_test", "1.0.0"); + FastMCP::ResourceTemplateOptions templ_opts; + AppConfig templ_app; + templ_app.domain = "https://widgets.example.test"; + templ_app.csp = Json{{"connectDomains", Json::array({"https://api.widgets.example.test"})}}; + templ_opts.app = templ_app; + + app.resource_template("ui://widgets/{id}.html", "widget", + [](const Json& params) + { + const std::string id = params.value("id", "unknown"); + return resources::ResourceContent{"ui://widgets/" + id + ".html", + std::nullopt, + std::string{"widget"}}; + }, + Json::object(), templ_opts); + + auto handler = mcp::make_mcp_handler(app); + handler(request(30, "initialize")); + + auto read = + handler(request(31, "resources/read", Json{{"uri", "ui://widgets/abc.html"}})); + CHECK_TRUE(read.contains("result") && read["result"].contains("contents"), + "resources/read should return contents"); + CHECK_TRUE(read["result"]["contents"].is_array() && read["result"]["contents"].size() == 1, + "resources/read should return one content block"); + const auto& content = read["result"]["contents"][0]; + CHECK_TRUE(content.contains("_meta") && content["_meta"].contains("ui"), + "templated resource read should include _meta.ui"); + CHECK_TRUE(content["_meta"]["ui"].value("domain", "") == "https://widgets.example.test", + "templated resource read should preserve app.domain"); + CHECK_TRUE(content["_meta"]["ui"].contains("csp"), "templated resource read should include app.csp"); + CHECK_TRUE(content["_meta"]["ui"]["csp"].contains("connectDomains"), + "templated resource read csp should include connectDomains"); + + return 0; +} + +static int test_initialize_advertises_ui_extension() +{ + std::cout << "test_initialize_advertises_ui_extension...\n"; + + FastMCP app("apps_extension_test", "1.0.0"); + FastMCP::ToolOptions opts; + AppConfig tool_app; + tool_app.resource_uri = "ui://widgets/app.html"; + opts.app = tool_app; + app.tool("dashboard", [](const Json&) { return Json{{"ok", true}}; }, opts); + + auto handler = mcp::make_mcp_handler(app); + auto init = handler(request(20, "initialize")); + CHECK_TRUE(init.contains("result"), "initialize should return result"); + CHECK_TRUE(init["result"].contains("capabilities"), "initialize missing capabilities"); + CHECK_TRUE(init["result"]["capabilities"].contains("extensions"), + "initialize should include capabilities.extensions"); + CHECK_TRUE(init["result"]["capabilities"]["extensions"].contains( + "io.modelcontextprotocol/ui"), + "initialize should advertise UI extension"); + + // Extension should also be advertised even if app has no explicit UI-bound resources/tools. + FastMCP bare("apps_extension_bare", "1.0.0"); + auto bare_handler = mcp::make_mcp_handler(bare); + auto bare_init = bare_handler(request(21, "initialize")); + CHECK_TRUE(bare_init.contains("result") && bare_init["result"].contains("capabilities"), + "initialize (bare) should include capabilities"); + CHECK_TRUE(bare_init["result"]["capabilities"].contains("extensions"), + "initialize (bare) should include capabilities.extensions"); + CHECK_TRUE(bare_init["result"]["capabilities"]["extensions"].contains( + "io.modelcontextprotocol/ui"), + "initialize (bare) should advertise UI extension"); + + return 0; +} + +static int test_resource_app_validation_rules() +{ + std::cout << "test_resource_app_validation_rules...\n"; + + FastMCP app("apps_validation_test", "1.0.0"); + + bool threw_resource = false; + try + { + FastMCP::ResourceOptions opts; + AppConfig invalid; + invalid.resource_uri = "ui://invalid"; + opts.app = invalid; + + app.resource("file://bad.txt", "bad", + [](const Json&) + { + return resources::ResourceContent{"file://bad.txt", std::nullopt, + std::string{"bad"}}; + }, + opts); + } + catch (const ValidationError&) + { + threw_resource = true; + } + CHECK_TRUE(threw_resource, "resource should reject app.resource_uri"); + + bool threw_template = false; + try + { + FastMCP::ResourceTemplateOptions opts; + AppConfig invalid; + invalid.visibility = std::vector{"tool_result"}; + opts.app = invalid; + + app.resource_template("file://{id}", "bad_templ", + [](const Json&) + { + return resources::ResourceContent{"file://x", std::nullopt, + std::string{"bad"}}; + }, + Json::object(), opts); + } + catch (const ValidationError&) + { + threw_template = true; + } + CHECK_TRUE(threw_template, "resource template should reject app.visibility"); + + return 0; +} + +int main() +{ + int rc = 0; + + rc = test_tool_meta_ui_emitted_and_parsed(); + if (rc != 0) + return rc; + + rc = test_resource_template_ui_defaults_and_meta(); + if (rc != 0) + return rc; + + rc = test_template_read_inherits_ui_meta(); + if (rc != 0) + return rc; + + rc = test_resource_app_validation_rules(); + if (rc != 0) + return rc; + + rc = test_initialize_advertises_ui_extension(); + if (rc != 0) + return rc; + + std::cout << "All MCP Apps tests passed\n"; + return 0; +} diff --git a/tests/cli/generated_cli_e2e.cpp b/tests/cli/generated_cli_e2e.cpp new file mode 100644 index 0000000..205dca0 --- /dev/null +++ b/tests/cli/generated_cli_e2e.cpp @@ -0,0 +1,283 @@ +#include "fastmcpp/types.hpp" +#include "fastmcpp/util/json.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +namespace +{ + +using fastmcpp::Json; + +struct CommandResult +{ + int exit_code = -1; + std::string output; +}; + +static std::string shell_quote(const std::string& value) +{ + if (value.find_first_of(" \t\"") == std::string::npos) + return value; + + std::string out = "\""; + for (char c : value) + { + if (c == '"') + out += "\\\""; + else + out.push_back(c); + } + out.push_back('"'); + return out; +} + +static bool contains(const std::string& haystack, const std::string& needle) +{ + return haystack.find(needle) != std::string::npos; +} + +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); + result.exit_code = WIFEXITED(rc) ? WEXITSTATUS(rc) : rc; +#endif + result.output = oss.str(); + return result; +} + +static int assert_result(const std::string& name, const CommandResult& result, int expected_exit, + const std::string& expected_substr) +{ + if (result.exit_code != expected_exit) + { + std::cerr << "[FAIL] " << name << ": exit_code=" << result.exit_code + << " expected=" << expected_exit << "\n" + << result.output << "\n"; + return 1; + } + if (!expected_substr.empty() && !contains(result.output, expected_substr)) + { + std::cerr << "[FAIL] " << name << ": missing output: " << expected_substr << "\n" + << result.output << "\n"; + return 1; + } + std::cout << "[OK] " << name << "\n"; + return 0; +} + +static std::string find_python_command() +{ + auto r = run_capture("python --version 2>&1"); + if (r.exit_code == 0) + return "python"; + r = run_capture("py -3 --version 2>&1"); + if (r.exit_code == 0) + return "py -3"; + return {}; +} + +static std::string make_env_command(const std::string& var, const std::string& value, + const std::string& command) +{ +#if defined(_WIN32) + return "set " + var + "=" + value + " && " + command; +#else + return var + "=" + shell_quote(value) + " " + command; +#endif +} + +} // namespace + +int main(int argc, char** argv) +{ + std::filesystem::path exe_dir = + std::filesystem::absolute(argc > 0 ? std::filesystem::path(argv[0]) : std::filesystem::path()) + .parent_path(); + std::filesystem::current_path(exe_dir); + +#if defined(_WIN32) + const auto fastmcpp_exe = exe_dir / "fastmcpp.exe"; + const auto stdio_server_exe = exe_dir / "fastmcpp_example_stdio_mcp_server.exe"; +#else + const auto fastmcpp_exe = exe_dir / "fastmcpp"; + const auto stdio_server_exe = exe_dir / "fastmcpp_example_stdio_mcp_server"; +#endif + + if (!std::filesystem::exists(fastmcpp_exe) || !std::filesystem::exists(stdio_server_exe)) + { + std::cerr << "[FAIL] required binaries not found in " << exe_dir.string() << "\n"; + return 1; + } + + const std::string python_cmd = find_python_command(); + if (python_cmd.empty()) + { + std::cout << "[SKIP] python interpreter not available; skipping generated CLI e2e\n"; + return 0; + } + + int failures = 0; + std::error_code ec; + + const std::filesystem::path stdio_script = "generated_cli_stdio_e2e.py"; + std::filesystem::remove(stdio_script, ec); + const std::string gen_stdio_cmd = + shell_quote(fastmcpp_exe.string()) + " generate-cli " + shell_quote(stdio_server_exe.string()) + + " " + shell_quote(stdio_script.string()) + " --no-skill --force --timeout 5 2>&1"; + failures += assert_result("generate-cli stdio script", run_capture(gen_stdio_cmd), 0, + "Generated CLI script"); + failures += assert_result( + "generated stdio list-tools", + run_capture(python_cmd + " " + shell_quote(stdio_script.string()) + " list-tools 2>&1"), 0, + "\"add\""); + failures += assert_result( + "generated stdio call-tool", + run_capture(python_cmd + " " + shell_quote(stdio_script.string()) + + " call-tool counter 2>&1"), + 0, "\"text\":\"1\""); + std::filesystem::remove(stdio_script, ec); + + const int port = 18990; + const std::string host = "127.0.0.1"; + std::atomic list_delay_ms{2000}; + httplib::Server srv; + srv.Post("/mcp", + [&](const httplib::Request& req, httplib::Response& res) + { + if (!req.has_header("Authorization") || + req.get_header_value("Authorization") != "Bearer secret-token") + { + res.status = 401; + res.set_content("{\"error\":\"unauthorized\"}", "application/json"); + return; + } + + auto rpc = fastmcpp::util::json::parse(req.body); + const auto method = rpc.value("method", std::string()); + const auto id = rpc.value("id", Json()); + if (method == "initialize") + { + Json response = {{"jsonrpc", "2.0"}, + {"id", id}, + {"result", + {{"protocolVersion", "2024-11-05"}, + {"serverInfo", {{"name", "auth-test"}, {"version", "1.0.0"}}}, + {"capabilities", Json::object()}}}}; + res.status = 200; + res.set_header("Mcp-Session-Id", "auth-test-session"); + res.set_content(response.dump(), "application/json"); + return; + } + if (method == "tools/list") + { + std::this_thread::sleep_for(std::chrono::milliseconds(list_delay_ms.load())); + Json response = { + {"jsonrpc", "2.0"}, + {"id", id}, + {"result", + {{"tools", + Json::array({Json{{"name", "secured_tool"}, + {"inputSchema", + Json{{"type", "object"}, + {"properties", Json::object()}}}, + {"description", "secured"}}})}}}}; + res.status = 200; + res.set_header("Mcp-Session-Id", "auth-test-session"); + res.set_content(response.dump(), "application/json"); + return; + } + + Json response = {{"jsonrpc", "2.0"}, + {"id", id}, + {"error", {{"code", -32601}, {"message", "method not found"}}}}; + res.status = 200; + res.set_content(response.dump(), "application/json"); + }); + + std::thread server_thread([&]() { srv.listen(host, port); }); + srv.wait_until_ready(); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + + const std::filesystem::path auth_script_ok = "generated_cli_auth_ok.py"; + std::filesystem::remove(auth_script_ok, ec); + const std::string base_url = "http://" + host + ":" + std::to_string(port) + "/mcp"; + failures += + assert_result("generate-cli auth script", + run_capture(shell_quote(fastmcpp_exe.string()) + " generate-cli " + + shell_quote(base_url) + " " + shell_quote(auth_script_ok.string()) + + " --no-skill --force --auth bearer --timeout 3 2>&1"), + 0, "Generated CLI script"); + + failures += + assert_result("generated auth requires env", + run_capture(python_cmd + " " + shell_quote(auth_script_ok.string()) + + " list-tools 2>&1"), + 2, "Missing FASTMCPP_AUTH_TOKEN"); + + failures += assert_result( + "generated auth list-tools success", + run_capture(make_env_command( + "FASTMCPP_AUTH_TOKEN", "secret-token", + python_cmd + " " + shell_quote(auth_script_ok.string()) + " list-tools 2>&1")), + 0, "\"secured_tool\""); + std::filesystem::remove(auth_script_ok, ec); + + const std::filesystem::path auth_script_timeout = "generated_cli_auth_timeout.py"; + std::filesystem::remove(auth_script_timeout, ec); + failures += + assert_result("generate-cli timeout script", + run_capture(shell_quote(fastmcpp_exe.string()) + " generate-cli " + + shell_quote(base_url) + " " + + shell_quote(auth_script_timeout.string()) + + " --no-skill --force --auth bearer --timeout 1 2>&1"), + 0, "Generated CLI script"); + + failures += assert_result( + "generated auth timeout enforced", + run_capture(make_env_command( + "FASTMCPP_AUTH_TOKEN", "secret-token", + python_cmd + " " + shell_quote(auth_script_timeout.string()) + " list-tools 2>&1")), + 124, "timed out"); + std::filesystem::remove(auth_script_timeout, ec); + + srv.stop(); + if (server_thread.joinable()) + server_thread.join(); + + return failures == 0 ? 0 : 1; +} diff --git a/tests/cli/tasks_cli.cpp b/tests/cli/tasks_cli.cpp index bfd8bfe..cc4269a 100644 --- a/tests/cli/tasks_cli.cpp +++ b/tests/cli/tasks_cli.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -135,5 +136,247 @@ int main(int argc, char** argv) failures += assert_contains("tasks list rejects unknown flag", r, 2, "Unknown option"); } + { + auto r = run_capture(base + " discover" + redir); + failures += assert_contains("discover requires connection", r, 2, "Missing connection options"); + } + + { + auto r = run_capture(base + " list tools" + redir); + failures += assert_contains("list requires connection", r, 2, "Missing connection options"); + } + + { + auto r = run_capture(base + " call" + redir); + failures += assert_contains("call requires tool name", r, 2, "Missing tool name"); + } + + { + auto r = run_capture(base + " call echo --args not-json --http http://127.0.0.1:1" + redir); + failures += assert_contains("call rejects invalid args json", r, 2, "Invalid --args JSON"); + } + + { + auto r = run_capture(base + " install goose" + redir); + failures += assert_contains("install goose prints command", r, 0, "goose mcp add fastmcpp"); + } + + { + auto r = run_capture(base + " install goose demo.server:app --with httpx --copy" + redir); + failures += assert_contains("install goose with server_spec", r, 0, "goose mcp add"); + failures += assert_contains("install goose includes uv launcher", r, 0, "uv"); + } + + { + auto r = run_capture(base + + " install stdio --name demo --command demo_srv --arg --mode --arg stdio --env A=B" + + redir); + failures += assert_contains("install stdio prints command", r, 0, "demo_srv"); + failures += assert_contains("install stdio includes args", r, 0, "--mode"); + } + + { + auto r = run_capture(base + " install mcp-json --name my_srv" + redir); + failures += assert_contains("install mcp-json alias", r, 0, "\"my_srv\""); + if (contains(r.output, "\"mcpServers\"")) + { + std::cerr << "[FAIL] install mcp-json should print direct entry without mcpServers\n"; + ++failures; + } + } + + { + auto r = run_capture(base + " install cursor --name demo --command srv" + redir); + failures += assert_contains("install cursor prints deeplink", r, 0, "cursor://anysphere.cursor-deeplink"); + } + + { + auto ws = std::filesystem::path("fastmcpp_cursor_ws_test"); + std::error_code ec; + std::filesystem::remove_all(ws, ec); + auto r = run_capture(base + " install cursor demo.server:app --name ws_demo --workspace " + + ws.string() + redir); + failures += assert_contains("install cursor workspace writes file", r, 0, + "Updated cursor workspace config"); + auto cursor_cfg = ws / ".cursor" / "mcp.json"; + if (!std::filesystem::exists(cursor_cfg)) + { + std::cerr << "[FAIL] install cursor workspace config missing: " << cursor_cfg.string() + << "\n"; + ++failures; + } + std::filesystem::remove_all(ws, ec); + } + + { + auto r = run_capture(base + " install claude-code --name demo --command srv --arg one" + redir); + failures += assert_contains("install claude-code command", r, 0, "claude mcp add"); + } + + { + auto r = run_capture(base + + " install mcp-json demo.server:app --name py_srv --with httpx --python 3.12" + + redir); + failures += assert_contains("install mcp-json builds uv launcher", r, 0, "\"command\": \"uv\""); + failures += assert_contains("install mcp-json includes fastmcp run", r, 0, "\"fastmcp\""); + failures += assert_contains("install mcp-json includes server spec", r, 0, "\"demo.server:app\""); + } + + { + auto r = run_capture(base + + " install mcp-json demo.server:app --with httpx --with-editable ./pkg --project . --with-requirements req.txt" + + redir); + failures += assert_contains("install mcp-json includes --with", r, 0, "\"--with\""); + failures += + assert_contains("install mcp-json includes --with-editable", r, 0, "\"--with-editable\""); + failures += + assert_contains("install mcp-json includes --with-requirements", r, 0, + "\"--with-requirements\""); + failures += assert_contains("install mcp-json includes --project", r, 0, "\"--project\""); + } + + { + auto r = run_capture(base + " install gemini-cli --name demo --command srv --arg one" + redir); + failures += assert_contains("install gemini-cli command", r, 0, "gemini mcp add"); + } + + { + auto r = run_capture(base + " install claude-desktop demo.server:app --name desktop_srv" + redir); + failures += assert_contains("install claude-desktop config", r, 0, "\"mcpServers\""); + failures += assert_contains("install claude-desktop includes server", r, 0, "\"desktop_srv\""); + } + + { + auto r = run_capture(base + " install claude --name demo --command srv --arg one" + redir); + failures += assert_contains("install claude alias", r, 0, "claude mcp add"); + } + + { + auto r = run_capture(base + " install nope" + redir); + failures += assert_contains("install rejects unknown target", r, 2, "Unknown install target"); + } + + { + auto out_file = std::filesystem::path("fastmcpp_cli_generated_test.py"); + auto skill_file = std::filesystem::path("SKILL.md"); + std::error_code ec; + std::filesystem::remove(out_file, ec); + std::filesystem::remove(skill_file, ec); + + auto r = run_capture(base + " generate-cli demo_server.py --output " + out_file.string() + + " --force" + redir); + failures += assert_contains("generate-cli creates file", r, 0, "Generated CLI script"); + failures += assert_contains("generate-cli creates skill", r, 0, "Generated SKILL.md"); + + if (!std::filesystem::exists(out_file)) + { + std::cerr << "[FAIL] generate-cli output file missing: " << out_file.string() << "\n"; + ++failures; + } + else + { + std::ifstream in(out_file); + std::stringstream content; + content << in.rdbuf(); + const auto script = content.str(); + if (!contains(script, "argparse") || !contains(script, "call-tool")) + { + std::cerr << "[FAIL] generate-cli script missing expected python CLI content\n"; + ++failures; + } + if (!contains(script, "DEFAULT_TIMEOUT = 30")) + { + std::cerr << "[FAIL] generate-cli script missing timeout default\n"; + ++failures; + } + if (!contains(script, "AUTH_MODE = 'none'")) + { + std::cerr << "[FAIL] generate-cli script missing AUTH_MODE default\n"; + ++failures; + } + std::filesystem::remove(out_file, ec); + } + + if (!std::filesystem::exists(skill_file)) + { + std::cerr << "[FAIL] generate-cli SKILL.md missing\n"; + ++failures; + } + else + { + std::filesystem::remove(skill_file, ec); + } + } + + { + auto out_file = std::filesystem::path("fastmcpp_cli_generated_positional.py"); + auto skill_file = std::filesystem::path("SKILL.md"); + std::error_code ec; + std::filesystem::remove(out_file, ec); + std::filesystem::remove(skill_file, ec); + + auto r = run_capture(base + " generate-cli demo_server.py " + out_file.string() + " --force" + + redir); + failures += assert_contains("generate-cli accepts positional output", r, 0, + "Generated CLI script"); + std::filesystem::remove(out_file, ec); + std::filesystem::remove(skill_file, ec); + } + + { + auto out_file = std::filesystem::path("cli.py"); + std::error_code ec; + std::filesystem::remove(out_file, ec); + auto r = run_capture(base + " generate-cli demo_server.py --no-skill --force" + redir); + failures += assert_contains("generate-cli default output", r, 0, "Generated CLI script"); + if (!std::filesystem::exists(out_file)) + { + std::cerr << "[FAIL] generate-cli default output file missing\n"; + ++failures; + } + std::filesystem::remove(out_file, ec); + } + + { + auto r = run_capture(base + " generate-cli --no-skill --force" + redir); + failures += + assert_contains("generate-cli requires server_spec", r, 2, "Missing server_spec"); + } + + { + auto r = run_capture(base + " generate-cli demo_server.py --auth invalid --no-skill --force" + + redir); + failures += assert_contains("generate-cli rejects invalid auth", r, 2, + "Unsupported --auth mode"); + } + + { + auto out_file = std::filesystem::path("fastmcpp_cli_generated_auth.py"); + std::error_code ec; + std::filesystem::remove(out_file, ec); + auto r = run_capture(base + + " generate-cli demo_server.py --auth bearer --timeout 7 --no-skill --force --output " + + out_file.string() + redir); + failures += assert_contains("generate-cli accepts auth+timeout", r, 0, "Generated CLI script"); + if (std::filesystem::exists(out_file)) + { + std::ifstream in(out_file); + std::stringstream content; + content << in.rdbuf(); + const auto script = content.str(); + if (!contains(script, "AUTH_MODE = 'bearer'") || !contains(script, "DEFAULT_TIMEOUT = 7")) + { + std::cerr << "[FAIL] generate-cli auth/timeout not rendered in script\n"; + ++failures; + } + } + else + { + std::cerr << "[FAIL] generate-cli auth output file missing\n"; + ++failures; + } + std::filesystem::remove(out_file, ec); + } + return failures == 0 ? 0 : 1; } diff --git a/tests/mcp/test_error_codes.cpp b/tests/mcp/test_error_codes.cpp new file mode 100644 index 0000000..144dbd3 --- /dev/null +++ b/tests/mcp/test_error_codes.cpp @@ -0,0 +1,89 @@ +/// @file test_error_codes.cpp +/// @brief Tests for MCP spec error codes in handler responses + +#include "fastmcpp/app.hpp" +#include "fastmcpp/mcp/handler.hpp" + +#include +#include + +using namespace fastmcpp; + +int main() +{ + // Build a FastMCP app with one tool but no resources or prompts + FastMCP app("test_error_codes", "1.0.0"); + app.tool( + "echo", + Json{{"type", "object"}, + {"properties", {{"msg", {{"type", "string"}}}}}, + {"required", Json::array({"msg"})}}, + [](const Json& args) { return Json{{"echo", args.at("msg")}}; }); + + auto handler = mcp::make_mcp_handler(app); + + // Initialize session + Json init = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}}; + auto init_resp = handler(init); + assert(init_resp.contains("result")); + + // Test 1: resources/read with nonexistent URI returns -32002 + { + Json req = {{"jsonrpc", "2.0"}, + {"id", 10}, + {"method", "resources/read"}, + {"params", {{"uri", "file:///nonexistent"}}}}; + auto resp = handler(req); + assert(resp.contains("error")); + assert(resp["error"]["code"].get() == -32002); + } + + // Test 2: prompts/get with nonexistent name returns -32001 + { + Json req = {{"jsonrpc", "2.0"}, + {"id", 11}, + {"method", "prompts/get"}, + {"params", {{"name", "nonexistent_prompt"}}}}; + auto resp = handler(req); + assert(resp.contains("error")); + assert(resp["error"]["code"].get() == -32001); + } + + // Test 3: tools/call with unknown tool returns -32602 + { + Json req = {{"jsonrpc", "2.0"}, + {"id", 12}, + {"method", "tools/call"}, + {"params", {{"name", "nonexistent_tool"}, {"arguments", Json::object()}}}}; + auto resp = handler(req); + assert(resp.contains("error")); + assert(resp["error"]["code"].get() == -32602); + } + + // Test 4: tools/call with missing tool name returns -32602 + { + Json req = {{"jsonrpc", "2.0"}, + {"id", 13}, + {"method", "tools/call"}, + {"params", {{"arguments", Json::object()}}}}; + auto resp = handler(req); + assert(resp.contains("error")); + assert(resp["error"]["code"].get() == -32602); + } + + // Test 5: tools/list and resources/list succeed normally + { + Json req = {{"jsonrpc", "2.0"}, {"id", 14}, {"method", "tools/list"}}; + auto resp = handler(req); + assert(resp.contains("result")); + assert(resp["result"]["tools"].size() == 1); + } + { + Json req = {{"jsonrpc", "2.0"}, {"id", 15}, {"method", "resources/list"}}; + auto resp = handler(req); + assert(resp.contains("result")); + assert(resp["result"]["resources"].is_array()); + } + + return 0; +} diff --git a/tests/mcp/test_pagination.cpp b/tests/mcp/test_pagination.cpp new file mode 100644 index 0000000..253eaa9 --- /dev/null +++ b/tests/mcp/test_pagination.cpp @@ -0,0 +1,111 @@ +/// @file test_pagination.cpp +/// @brief Tests for cursor-based pagination utilities + +#include "fastmcpp/util/pagination.hpp" + +#include +#include +#include + +using namespace fastmcpp::util::pagination; + +void test_cursor_encode_decode_round_trip() +{ + auto encoded = encode_cursor(42); + auto decoded = decode_cursor(encoded); + assert(decoded.offset == 42); +} + +void test_cursor_decode_invalid_returns_zero() +{ + // Invalid base64 / non-JSON should return offset 0 + auto decoded = decode_cursor("not_valid_base64!!!"); + assert(decoded.offset == 0); + + // Empty string + auto decoded2 = decode_cursor(""); + assert(decoded2.offset == 0); +} + +void test_paginate_sequence_basic() +{ + std::vector items = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + // Page 1: first 3 items (no cursor) + auto page1 = paginate_sequence(items, std::nullopt, 3); + assert(page1.items.size() == 3); + assert(page1.items[0] == 1); + assert(page1.items[1] == 2); + assert(page1.items[2] == 3); + assert(page1.next_cursor.has_value()); + + // Page 2: next 3 items + auto page2 = paginate_sequence(items, page1.next_cursor, 3); + assert(page2.items.size() == 3); + assert(page2.items[0] == 4); + assert(page2.items[1] == 5); + assert(page2.items[2] == 6); + assert(page2.next_cursor.has_value()); + + // Page 3: next 3 items + auto page3 = paginate_sequence(items, page2.next_cursor, 3); + assert(page3.items.size() == 3); + assert(page3.items[0] == 7); + assert(page3.items[1] == 8); + assert(page3.items[2] == 9); + assert(page3.next_cursor.has_value()); + + // Page 4: last item, no more pages + auto page4 = paginate_sequence(items, page3.next_cursor, 3); + assert(page4.items.size() == 1); + assert(page4.items[0] == 10); + assert(!page4.next_cursor.has_value()); +} + +void test_paginate_sequence_no_pagination() +{ + std::vector items = {1, 2, 3}; + + // page_size 0 means no pagination - return all + auto result = paginate_sequence(items, std::nullopt, 0); + assert(result.items.size() == 3); + assert(!result.next_cursor.has_value()); +} + +void test_paginate_sequence_exact_fit() +{ + std::vector items = {1, 2, 3}; + + // Exactly fills one page - no next cursor + auto result = paginate_sequence(items, std::nullopt, 3); + assert(result.items.size() == 3); + assert(!result.next_cursor.has_value()); +} + +void test_paginate_sequence_empty() +{ + std::vector items; + auto result = paginate_sequence(items, std::nullopt, 5); + assert(result.items.empty()); + assert(!result.next_cursor.has_value()); +} + +void test_base64_round_trip() +{ + std::string input = "{\"offset\":99}"; + auto encoded = base64_encode(input); + auto decoded = base64_decode(encoded); + assert(decoded == input); +} + +int main() +{ + test_cursor_encode_decode_round_trip(); + test_cursor_decode_invalid_returns_zero(); + test_paginate_sequence_basic(); + test_paginate_sequence_no_pagination(); + test_paginate_sequence_exact_fit(); + test_paginate_sequence_empty(); + test_base64_round_trip(); + return 0; +} diff --git a/tests/providers/openapi_provider.cpp b/tests/providers/openapi_provider.cpp new file mode 100644 index 0000000..9f96382 --- /dev/null +++ b/tests/providers/openapi_provider.cpp @@ -0,0 +1,157 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/providers/openapi_provider.hpp" + +#include + +#include +#include +#include + +using namespace fastmcpp; + +int main() +{ + httplib::Server server; + + server.Get(R"(/api/users/([^/]+))", [](const httplib::Request& req, httplib::Response& res) + { + Json body = { + {"id", req.matches[1].str()}, + {"verbose", req.has_param("verbose") ? req.get_param_value("verbose") : "false"}, + }; + res.set_content(body.dump(), "application/json"); + }); + + server.Post("/api/echo", [](const httplib::Request& req, httplib::Response& res) + { res.set_content(req.body, "application/json"); }); + + std::thread server_thread([&]() + { server.listen("127.0.0.1", 18888); }); + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + + Json spec = Json::object(); + spec["openapi"] = "3.0.3"; + spec["info"] = Json{{"title", "Test API"}, {"version", "2.1.0"}}; + spec["servers"] = Json::array({Json{{"url", "http://127.0.0.1:18888"}}}); + spec["paths"] = Json::object(); + spec["paths"]["/api/users/{id}"]["parameters"] = Json::array({ + Json{{"name", "verbose"}, + {"in", "query"}, + {"required", false}, + {"description", "path-level verbose (should be overridden)"}, + {"schema", Json{{"type", "string"}}}}, + }); + + spec["paths"]["/api/users/{id}"]["get"] = Json{ + {"operationId", "getUser"}, + {"parameters", + Json::array({ + Json{{"name", "id"}, + {"in", "path"}, + {"required", true}, + {"schema", Json{{"type", "string"}}}}, + Json{{"name", "verbose"}, + {"in", "query"}, + {"required", true}, + {"description", "operation-level verbose flag"}, + {"schema", Json{{"type", "boolean"}}}}, + })}, + {"responses", + Json{{"200", + Json{{"description", "ok"}, + {"content", + Json{{"application/json", + Json{{"schema", + Json{{"type", "object"}, + {"properties", Json{{"id", Json{{"type", "string"}}}}}}}}}}}}}}}, + }; + + spec["paths"]["/api/echo"]["post"] = Json{ + {"operationId", "echoPayload"}, + {"requestBody", + Json{{"required", true}, + {"content", + Json{{"application/json", + Json{{"schema", + Json{{"type", "object"}, + {"properties", + Json{{"message", Json{{"type", "string"}}}}}}}}}}}}}, + {"responses", + Json{{"200", + Json{{"description", "ok"}, + {"content", + Json{{"application/json", + Json{{"schema", + Json{{"type", "object"}, + {"properties", + Json{{"message", Json{{"type", "string"}}}}}}}}}}}}}}}, + }; + + auto provider = std::make_shared(spec); + FastMCP app("openapi_provider", "1.0.0"); + app.add_provider(provider); + + auto tools = app.list_all_tools_info(); + assert(tools.size() == 2); + bool checked_override = false; + for (const auto& tool : tools) + { + if (tool.name != "getuser") + continue; + + assert(tool.inputSchema["properties"].contains("verbose")); + assert(tool.inputSchema["properties"]["verbose"]["type"] == "boolean"); + assert(tool.inputSchema["properties"]["verbose"]["description"] == + "operation-level verbose flag"); + + bool verbose_required = false; + for (const auto& required_entry : tool.inputSchema["required"]) + { + if (required_entry == "verbose") + { + verbose_required = true; + break; + } + } + assert(verbose_required); + checked_override = true; + break; + } + assert(checked_override); + + auto user = app.invoke_tool("getuser", Json{{"id", "42"}, {"verbose", true}}); + assert(user["id"] == "42"); + assert(user["verbose"] == "true"); + + auto echoed = app.invoke_tool("echopayload", Json{{"body", Json{{"message", "hello"}}}}); + assert(echoed["message"] == "hello"); + + providers::OpenAPIProvider::Options opts; + opts.validate_output = false; + opts.mcp_names["getUser"] = "Fetch User"; + auto provider_with_opts = + std::make_shared(spec, std::nullopt, opts); + FastMCP app_with_opts("openapi_provider_opts", "1.0.0"); + app_with_opts.add_provider(provider_with_opts); + + auto tools_with_opts = app_with_opts.list_all_tools_info(); + bool found_mapped_name = false; + for (const auto& tool : tools_with_opts) + { + if (tool.name == "fetch_user") + { + found_mapped_name = true; + assert(tool.outputSchema.has_value()); + assert(tool.outputSchema->is_object()); + assert(tool.outputSchema->value("type", "") == "object"); + assert(tool.outputSchema->value("additionalProperties", false) == true); + break; + } + } + assert(found_mapped_name); + + server.stop(); + server_thread.join(); + + return 0; +} diff --git a/tests/providers/skills_provider.cpp b/tests/providers/skills_provider.cpp new file mode 100644 index 0000000..171b133 --- /dev/null +++ b/tests/providers/skills_provider.cpp @@ -0,0 +1,165 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/providers/skills_provider.hpp" + +#include +#include +#include +#include +#include + +using namespace fastmcpp; + +namespace +{ +std::filesystem::path make_temp_dir(const std::string& name) +{ + auto base = std::filesystem::temp_directory_path() / ("fastmcpp_skills_" + name); + std::error_code ec; + std::filesystem::remove_all(base, ec); + std::filesystem::create_directories(base); + return base; +} + +void write_text(const std::filesystem::path& path, const std::string& text) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << text; +} + +std::string read_text_data(const resources::ResourceContent& content) +{ + if (auto* text = std::get_if(&content.data)) + return *text; + return {}; +} +} // namespace + +int main() +{ + const auto root = make_temp_dir("single"); + const auto skill = root / "pdf-processing"; + write_text(skill / "SKILL.md", + "---\n" + "description: \"Frontmatter PDF skill\"\n" + "version: \"1.0.0\"\n" + "---\n\n" + "# PDF Processing\nRead PDF files."); + write_text(skill / "notes" / "guide.txt", "guide"); + + auto provider = + std::make_shared(skill, "SKILL.md", providers::SkillSupportingFiles::Template); + FastMCP app("skills", "1.0.0"); + app.add_provider(provider); + + auto resources = app.list_all_resources(); + assert(resources.size() == 2); + bool found_main_resource = false; + for (const auto& res : resources) + { + if (res.uri == "skill://pdf-processing/SKILL.md") + { + assert(res.description.has_value()); + assert(*res.description == "Frontmatter PDF skill"); + found_main_resource = true; + break; + } + } + assert(found_main_resource); + auto main = app.read_resource("skill://pdf-processing/SKILL.md"); + assert(read_text_data(main).find("PDF Processing") != std::string::npos); + auto manifest = app.read_resource("skill://pdf-processing/_manifest"); + const std::string manifest_text = read_text_data(manifest); + assert(manifest_text.find("notes/guide.txt") != std::string::npos); + assert(manifest_text.find("\"hash\"") != std::string::npos); + auto manifest_json = Json::parse(manifest_text); + bool found_expected_hash = false; + for (const auto& entry : manifest_json["files"]) + { + if (entry.value("path", "") == "notes/guide.txt") + { + found_expected_hash = + entry.value("hash", "") == + "sha256:83ca68be6227af2feb15f227485ed18aff8ecae99416a4bd6df3be1b5e8059b4"; + break; + } + } + assert(found_expected_hash); + + auto templates = app.list_all_templates(); + assert(templates.size() == 1); + auto guide = app.read_resource("skill://pdf-processing/notes/guide.txt"); + assert(read_text_data(guide) == "guide"); + + auto resources_mode_provider = std::make_shared( + skill, "SKILL.md", providers::SkillSupportingFiles::Resources); + FastMCP app_resources("skills_resources", "1.0.0"); + app_resources.add_provider(resources_mode_provider); + auto resources_mode_list = app_resources.list_all_resources(); + bool found_extra = false; + for (const auto& res : resources_mode_list) + { + if (res.uri == "skill://pdf-processing/notes/guide.txt") + { + found_extra = true; + break; + } + } + assert(found_extra); + + const auto root_a = make_temp_dir("a"); + const auto root_b = make_temp_dir("b"); + write_text(root_a / "alpha" / "SKILL.md", "# Alpha\nfrom root A"); + write_text(root_b / "alpha" / "SKILL.md", "# Alpha\nfrom root B"); + write_text(root_b / "beta" / "SKILL.md", "# Beta\nfrom root B"); + + auto dir_provider = std::make_shared( + std::vector{root_a, root_b}, false, "SKILL.md", + providers::SkillSupportingFiles::Template); + FastMCP app_dir("skills_dir", "1.0.0"); + app_dir.add_provider(dir_provider); + + auto alpha = app_dir.read_resource("skill://alpha/SKILL.md"); + assert(read_text_data(alpha).find("root A") != std::string::npos); + auto beta = app_dir.read_resource("skill://beta/SKILL.md"); + assert(read_text_data(beta).find("root B") != std::string::npos); + + auto single_root_provider = std::make_shared( + root_a, false, "SKILL.md", providers::SkillSupportingFiles::Template); + FastMCP app_single("skills_single_root", "1.0.0"); + app_single.add_provider(single_root_provider); + auto single_resources = app_single.list_all_resources(); + assert(single_resources.size() == 2); + + auto alias_provider = + std::make_shared(std::vector{root_b}); + FastMCP app_alias("skills_alias", "1.0.0"); + app_alias.add_provider(alias_provider); + auto alias_resources = app_alias.list_all_resources(); + assert(alias_resources.size() == 4); + + // Vendor directory providers should construct and enumerate without throwing. + providers::ClaudeSkillsProvider claude_provider; + providers::CursorSkillsProvider cursor_provider; + providers::VSCodeSkillsProvider vscode_provider; + providers::CodexSkillsProvider codex_provider; + providers::GeminiSkillsProvider gemini_provider; + providers::GooseSkillsProvider goose_provider; + providers::CopilotSkillsProvider copilot_provider; + providers::OpenCodeSkillsProvider opencode_provider; + (void)claude_provider.list_resources(); + (void)cursor_provider.list_resources(); + (void)vscode_provider.list_resources(); + (void)codex_provider.list_resources(); + (void)gemini_provider.list_resources(); + (void)goose_provider.list_resources(); + (void)copilot_provider.list_resources(); + (void)opencode_provider.list_resources(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::remove_all(root_a, ec); + std::filesystem::remove_all(root_b, ec); + + return 0; +} diff --git a/tests/providers/version_filter.cpp b/tests/providers/version_filter.cpp new file mode 100644 index 0000000..4e64116 --- /dev/null +++ b/tests/providers/version_filter.cpp @@ -0,0 +1,131 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/providers/local_provider.hpp" +#include "fastmcpp/providers/transforms/version_filter.hpp" + +#include +#include +#include +#include + +using namespace fastmcpp; + +namespace +{ +tools::Tool make_tool(const std::string& name, const std::string& version, int value) +{ + tools::Tool tool(name, Json::object(), Json::object(), [value](const Json&) { return Json(value); }); + if (!version.empty()) + tool.set_version(version); + return tool; +} + +resources::Resource make_resource(const std::string& uri, const std::string& version) +{ + resources::Resource resource; + resource.uri = uri; + resource.name = uri; + if (!version.empty()) + resource.version = version; + resource.provider = [uri](const Json&) + { + resources::ResourceContent content; + content.uri = uri; + content.data = std::string("ok"); + return content; + }; + return resource; +} + +resources::ResourceTemplate make_template(const std::string& uri_templ, const std::string& version) +{ + resources::ResourceTemplate templ; + templ.uri_template = uri_templ; + templ.name = uri_templ; + if (!version.empty()) + templ.version = version; + templ.parameters = Json::object(); + templ.provider = [](const Json&) + { + resources::ResourceContent content; + content.uri = "res://template"; + content.data = std::string("ok"); + return content; + }; + templ.parse(); + return templ; +} + +prompts::Prompt make_prompt(const std::string& name, const std::string& version) +{ + prompts::Prompt prompt; + prompt.name = name; + if (!version.empty()) + prompt.version = version; + prompt.generator = [](const Json&) + { return std::vector{{"user", "hello"}}; }; + return prompt; +} +} // namespace + +int main() +{ + auto provider = std::make_shared(); + provider->add_tool(make_tool("legacy_tool", "1.9.0", 1)); + provider->add_tool(make_tool("v2_tool", "2.3.0", 2)); + provider->add_tool(make_tool("no_version_tool", "", 3)); + provider->add_resource(make_resource("res://legacy", "1.0")); + provider->add_resource(make_resource("res://v2", "2.0")); + provider->add_template(make_template("res://legacy/{id}", "1.0")); + provider->add_template(make_template("res://v2/{id}", "2.0")); + provider->add_prompt(make_prompt("legacy_prompt", "1.0")); + provider->add_prompt(make_prompt("v2_prompt", "2.0")); + provider->add_transform( + std::make_shared(std::string("2.0"), std::string("3.0"))); + + FastMCP app("version_filter", "1.0.0"); + app.add_provider(provider); + + std::vector tools; + for (const auto& info : app.list_all_tools_info()) + tools.push_back(info.name); + assert(tools.size() == 2); + bool saw_v2 = false; + bool saw_unversioned = false; + for (const auto& tool_name : tools) + { + if (tool_name == "v2_tool") + saw_v2 = true; + if (tool_name == "no_version_tool") + saw_unversioned = true; + } + assert(saw_v2); + assert(saw_unversioned); + + auto result = app.invoke_tool("v2_tool", Json::object()); + assert(result == 2); + auto no_version = app.invoke_tool("no_version_tool", Json::object()); + assert(no_version == 3); + try + { + app.invoke_tool("legacy_tool", Json::object()); + assert(false); + } + catch (const NotFoundError&) + { + } + + auto resources = app.list_all_resources(); + assert(resources.size() == 1); + assert(resources[0].uri == "res://v2"); + + auto templates = app.list_all_templates(); + assert(templates.size() == 1); + assert(templates[0].uri_template == "res://v2/{id}"); + + auto prompts = app.list_all_prompts(); + assert(prompts.size() == 1); + assert(prompts[0].first == "v2_prompt"); + + return 0; +} diff --git a/tests/proxy/basic.cpp b/tests/proxy/basic.cpp index 2421d0d..7a68ecf 100644 --- a/tests/proxy/basic.cpp +++ b/tests/proxy/basic.cpp @@ -396,17 +396,15 @@ void test_create_proxy_url_detection() assert(false); } - // WebSocket URL - should create WebSocketTransport + // WebSocket URL - unsupported (fastmcpp follows Python transport surface) try { - auto proxy = create_proxy(std::string("ws://localhost:9999/mcp"), "WsProxy"); - assert(proxy.name() == "WsProxy"); - std::cout << " WS URL: OK" << std::endl; + (void)create_proxy(std::string("ws://localhost:9999/mcp"), "WsProxy"); + assert(false); // Should have thrown } - catch (const std::exception& e) + catch (const std::invalid_argument&) { - std::cerr << " WS URL failed unexpectedly: " << e.what() << std::endl; - assert(false); + std::cout << " WS URL: correctly rejected" << std::endl; } // Invalid URL scheme - should throw diff --git a/tests/schema/dereference_toggle.cpp b/tests/schema/dereference_toggle.cpp new file mode 100644 index 0000000..e3f6292 --- /dev/null +++ b/tests/schema/dereference_toggle.cpp @@ -0,0 +1,139 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/mcp/handler.hpp" + +#include + +using namespace fastmcpp; + +namespace +{ +Json make_tool_input_schema() +{ + return Json{ + {"type", "object"}, + {"$defs", Json{{"City", Json{{"type", "string"}, {"enum", Json::array({"sf", "nyc"})}}}}}, + {"properties", + Json{{"city", + Json{ + {"$ref", "#/$defs/City"}, + {"description", "City name"}, + }}}}, + {"required", Json::array({"city"})}, + }; +} + +Json make_tool_output_schema() +{ + return Json{ + {"type", "object"}, + {"$defs", Json{{"Degrees", Json{{"type", "integer"}}}}}, + {"properties", Json{{"temperature", Json{{"$ref", "#/$defs/Degrees"}}}}}, + {"required", Json::array({"temperature"})}, + }; +} + +Json make_template_parameters_schema() +{ + return Json{ + {"type", "object"}, + {"$defs", Json{{"Path", Json{{"type", "string"}}}}}, + {"properties", Json{{"path", Json{{"$ref", "#/$defs/Path"}}}}}, + {"required", Json::array({"path"})}, + }; +} + +bool contains_ref_recursive(const Json& value) +{ + if (value.is_object()) + { + if (value.contains("$ref")) + return true; + for (const auto& [_, child] : value.items()) + if (contains_ref_recursive(child)) + return true; + return false; + } + if (value.is_array()) + { + for (const auto& child : value) + if (contains_ref_recursive(child)) + return true; + } + return false; +} + +void register_components(FastMCP& app) +{ + FastMCP::ToolOptions opts; + opts.output_schema = make_tool_output_schema(); + app.tool("weather", make_tool_input_schema(), + [](const Json&) + { return Json{{"temperature", 70}}; }, opts); + + app.resource_template("skill://demo/{path*}", "skill_files", + [](const Json&) + { + resources::ResourceContent content; + content.uri = "skill://demo/readme"; + content.data = std::string("ok"); + return content; + }, + make_template_parameters_schema()); +} + +void test_dereference_enabled_by_default() +{ + FastMCP app("schema_default_on", "1.0.0"); + register_components(app); + + auto handler = mcp::make_mcp_handler(app); + handler(Json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}}); + + auto tools_resp = handler(Json{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "tools/list"}}); + assert(tools_resp.contains("result")); + const auto& tool = tools_resp["result"]["tools"][0]; + const auto& input_schema = tool["inputSchema"]; + assert(!contains_ref_recursive(input_schema)); + assert(input_schema["properties"]["city"]["description"] == "City name"); + assert(input_schema["properties"]["city"]["enum"] == Json::array({"sf", "nyc"})); + assert(!input_schema.contains("$defs")); + assert(!contains_ref_recursive(tool["outputSchema"])); + + auto templates_resp = + handler(Json{{"jsonrpc", "2.0"}, {"id", 3}, {"method", "resources/templates/list"}}); + assert(templates_resp.contains("result")); + const auto& templ = templates_resp["result"]["resourceTemplates"][0]; + assert(!contains_ref_recursive(templ["parameters"])); + assert(!templ["parameters"].contains("$defs")); +} + +void test_dereference_can_be_disabled() +{ + FastMCP app("schema_default_off", "1.0.0", std::nullopt, std::nullopt, {}, 0, false); + register_components(app); + + auto handler = mcp::make_mcp_handler(app); + handler(Json{{"jsonrpc", "2.0"}, {"id", 4}, {"method", "initialize"}}); + + auto tools_resp = handler(Json{{"jsonrpc", "2.0"}, {"id", 5}, {"method", "tools/list"}}); + assert(tools_resp.contains("result")); + const auto& tool = tools_resp["result"]["tools"][0]; + assert(contains_ref_recursive(tool["inputSchema"])); + assert(tool["inputSchema"].contains("$defs")); + assert(contains_ref_recursive(tool["outputSchema"])); + + auto templates_resp = + handler(Json{{"jsonrpc", "2.0"}, {"id", 6}, {"method", "resources/templates/list"}}); + assert(templates_resp.contains("result")); + const auto& templ = templates_resp["result"]["resourceTemplates"][0]; + assert(contains_ref_recursive(templ["parameters"])); + assert(templ["parameters"].contains("$defs")); +} +} // namespace + +int main() +{ + test_dereference_enabled_by_default(); + test_dereference_can_be_disabled(); + return 0; +} diff --git a/tests/server/streamable_http_integration.cpp b/tests/server/streamable_http_integration.cpp index 17dfc56..f730199 100644 --- a/tests/server/streamable_http_integration.cpp +++ b/tests/server/streamable_http_integration.cpp @@ -382,6 +382,67 @@ void test_error_handling() server.stop(); } +void test_invalid_tool_maps_to_invalid_params_error_code() +{ + std::cout << " test_invalid_tool_maps_to_invalid_params_error_code... " << std::flush; + + const int port = 18357; + const std::string host = "127.0.0.1"; + + tools::ToolManager tool_mgr; + std::unordered_map descriptions; + auto handler = mcp::make_mcp_handler("invalid_tool_error_code", "1.0.0", tool_mgr, descriptions); + + server::StreamableHttpServerWrapper server(handler, host, port, "/mcp"); + bool started = server.start(); + assert(started && "Server failed to start"); + + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + try + { + httplib::Client cli(host, port); + cli.set_connection_timeout(5, 0); + cli.set_read_timeout(5, 0); + + Json init_request = {{"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "initialize"}, + {"params", + {{"protocolVersion", "2024-11-05"}, + {"capabilities", Json::object()}, + {"clientInfo", {{"name", "test"}, {"version", "1.0"}}}}}}; + + auto init_res = cli.Post("/mcp", init_request.dump(), "application/json"); + assert(init_res && init_res->status == 200); + std::string session_id = init_res->get_header_value("Mcp-Session-Id"); + assert(!session_id.empty()); + + Json bad_call = {{"jsonrpc", "2.0"}, + {"id", 2}, + {"method", "tools/call"}, + {"params", {{"name", "missing_tool"}, {"arguments", Json::object()}}}}; + + httplib::Headers headers = {{"Mcp-Session-Id", session_id}}; + auto bad_res = cli.Post("/mcp", headers, bad_call.dump(), "application/json"); + assert(bad_res && bad_res->status == 200); + + auto rpc_response = fastmcpp::util::json::parse(bad_res->body); + assert(rpc_response.contains("error")); + assert(rpc_response["error"]["code"].get() == -32602); + + std::cout << "PASSED\n"; + } + catch (const std::exception& e) + { + std::cout << "FAILED: " << e.what() << "\n"; + server.stop(); + throw; + } + + server.stop(); +} + void test_default_timeout_allows_slow_tool() { std::cout << " test_default_timeout_allows_slow_tool... " << std::flush; @@ -536,6 +597,7 @@ int main() test_session_management(); test_server_info(); test_error_handling(); + test_invalid_tool_maps_to_invalid_params_error_code(); test_default_timeout_allows_slow_tool(); test_notification_handling(); diff --git a/tests/server/test_response_limiting.cpp b/tests/server/test_response_limiting.cpp new file mode 100644 index 0000000..c428cb2 --- /dev/null +++ b/tests/server/test_response_limiting.cpp @@ -0,0 +1,158 @@ +/// @file test_response_limiting.cpp +/// @brief Tests for ResponseLimitingMiddleware + +#include "fastmcpp/server/response_limiting_middleware.hpp" +#include "fastmcpp/server/server.hpp" +#include "fastmcpp/types.hpp" + +#include +#include + +using namespace fastmcpp; +using namespace fastmcpp::server; + +void test_response_under_limit_unchanged() +{ + ResponseLimitingMiddleware mw(100); + auto hook = mw.make_hook(); + + Json response = { + {"content", Json::array({{{"type", "text"}, {"text", "short response"}}})}}; + hook("tools/call", Json::object(), response); + + assert(response["content"][0]["text"] == "short response"); +} + +void test_response_over_limit_truncated() +{ + ResponseLimitingMiddleware mw(20, "..."); + auto hook = mw.make_hook(); + + std::string long_text(50, 'A'); + Json response = {{"content", Json::array({{{"type", "text"}, {"text", long_text}}})}}; + hook("tools/call", Json::object(), response); + + auto result = response["content"][0]["text"].get(); + assert(result.size() <= 23); // 20 + "..." + assert(result.find("...") != std::string::npos); +} + +void test_non_tools_call_route_unchanged() +{ + ResponseLimitingMiddleware mw(10); + auto hook = mw.make_hook(); + + std::string long_text(50, 'B'); + Json response = {{"content", Json::array({{{"type", "text"}, {"text", long_text}}})}}; + hook("resources/read", Json::object(), response); + + // Should not be truncated — middleware only applies to tools/call + assert(response["content"][0]["text"].get().size() == 50); +} + +void test_tool_filter_applies_only_to_specified_tools() +{ + ResponseLimitingMiddleware mw(10, "...", {"allowed_tool"}); + auto hook = mw.make_hook(); + + std::string long_text(50, 'C'); + + // Call with name matching filter: should be truncated + Json response1 = {{"content", Json::array({{{"type", "text"}, {"text", long_text}}})}}; + Json payload1 = {{"name", "allowed_tool"}}; + hook("tools/call", payload1, response1); + assert(response1["content"][0]["text"].get().size() < 50); + + // Call with name not matching filter: should NOT be truncated + Json response2 = {{"content", Json::array({{{"type", "text"}, {"text", long_text}}})}}; + Json payload2 = {{"name", "other_tool"}}; + hook("tools/call", payload2, response2); + assert(response2["content"][0]["text"].get().size() == 50); +} + +void test_utf8_boundary_not_split() +{ + // Create a string with multi-byte UTF-8 characters + // U+00E9 (é) = 0xC3 0xA9 (2 bytes) + std::string text; + for (int i = 0; i < 10; i++) + text += "\xC3\xA9"; // 20 bytes, 10 chars + + // Set limit right in the middle of a 2-byte character + ResponseLimitingMiddleware mw(11, "..."); + auto hook = mw.make_hook(); + + Json response = {{"content", Json::array({{{"type", "text"}, {"text", text}}})}}; + hook("tools/call", Json::object(), response); + + auto result = response["content"][0]["text"].get(); + // Should not split a multi-byte character. + // Verify: every byte that's 0x80-0xBF (continuation) should be preceded by a valid leader + for (size_t i = 0; i < result.size(); i++) + { + unsigned char c = static_cast(result[i]); + // A continuation byte (10xxxxxx) should not appear at position 0 + // and should follow a leader byte + if (i == 0) + assert((c & 0xC0) != 0x80); + } +} + +void test_non_text_content_unchanged() +{ + ResponseLimitingMiddleware mw(10); + auto hook = mw.make_hook(); + + // Image content type should not be truncated + Json response = { + {"content", Json::array({{{"type", "image"}, {"data", std::string(50, 'D')}}})}}; + hook("tools/call", Json::object(), response); + + assert(response["content"][0]["data"].get().size() == 50); +} + +void test_jsonrpc_envelope_response_truncated() +{ + ResponseLimitingMiddleware mw(12, "..."); + auto hook = mw.make_hook(); + + std::string long_text(40, 'E'); + Json response = { + {"result", {{"content", Json::array({{{"type", "text"}, {"text", long_text}}})}}}}; + hook("tools/call", Json::object(), response); + + auto result = response["result"]["content"][0]["text"].get(); + assert(result.size() <= 15); // 12 + "..." + assert(result.find("...") != std::string::npos); +} + +void test_server_after_hook_integration() +{ + ResponseLimitingMiddleware mw(16, "...", {"long_tool"}); + Server server("response_limit", "1.0.0"); + server.add_after(mw.make_hook()); + server.route("tools/call", + [](const Json&) + { + return Json{ + {"content", Json::array({{{"type", "text"}, {"text", std::string(80, 'F')}}})}}; + }); + + Json response = server.handle("tools/call", Json{{"name", "long_tool"}}); + auto text = response["content"][0]["text"].get(); + assert(text.size() <= 19); // 16 + "..." + assert(text.find("...") != std::string::npos); +} + +int main() +{ + test_response_under_limit_unchanged(); + test_response_over_limit_truncated(); + test_non_tools_call_route_unchanged(); + test_tool_filter_applies_only_to_specified_tools(); + test_utf8_boundary_not_split(); + test_non_text_content_unchanged(); + test_jsonrpc_envelope_response_truncated(); + test_server_after_hook_integration(); + return 0; +} diff --git a/tests/server/test_server_session.cpp b/tests/server/test_server_session.cpp index e199b95..ae4febe 100644 --- a/tests/server/test_server_session.cpp +++ b/tests/server/test_server_session.cpp @@ -62,6 +62,26 @@ void test_set_capabilities() std::cout << "PASSED\n"; } +void test_extension_capabilities() +{ + std::cout << " test_extension_capabilities... " << std::flush; + + ServerSession session("sess_ext", nullptr); + Json caps = { + {"tools", Json::object()}, + {"extensions", + Json{{"io.modelcontextprotocol/ui", Json::object()}, + {"example.extension", Json{{"enabled", true}}}}}, + }; + session.set_capabilities(caps); + + assert(session.supports_extension("io.modelcontextprotocol/ui")); + assert(session.supports_extension("example.extension")); + assert(!session.supports_extension("missing.extension")); + + std::cout << "PASSED\n"; +} + void test_is_response_request_notification() { std::cout << " test_is_response_request_notification... " << std::flush; @@ -395,6 +415,7 @@ int main() { test_session_creation(); test_set_capabilities(); + test_extension_capabilities(); test_is_response_request_notification(); test_send_request_and_response(); test_request_timeout(); diff --git a/tests/server/test_session_state.cpp b/tests/server/test_session_state.cpp new file mode 100644 index 0000000..aaf84db --- /dev/null +++ b/tests/server/test_session_state.cpp @@ -0,0 +1,137 @@ +/// @file test_session_state.cpp +/// @brief Tests for session-scoped state in Context + +#include "fastmcpp/prompts/manager.hpp" +#include "fastmcpp/resources/manager.hpp" +#include "fastmcpp/server/context.hpp" + +#include +#include +#include +#include + +using namespace fastmcpp; +using namespace fastmcpp::server; + +void test_set_and_get_session_state() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + auto state = std::make_shared(); + Context ctx(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state); + + ctx.set_session_state("counter", 42); + auto val = ctx.get_session_state("counter"); + assert(std::any_cast(val) == 42); +} + +void test_shared_session_state_between_contexts() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + auto state = std::make_shared(); + Context ctx1(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state); + Context ctx2(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state); + + ctx1.set_session_state("shared_key", std::string("hello")); + auto val = ctx2.get_session_state("shared_key"); + assert(std::any_cast(val) == "hello"); +} + +void test_independent_session_state() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + auto state1 = std::make_shared(); + auto state2 = std::make_shared(); + Context ctx1(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state1); + Context ctx2(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state2); + + ctx1.set_session_state("key", 100); + assert(!ctx2.has_session_state("key")); +} + +void test_get_session_state_or_default() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + auto state = std::make_shared(); + Context ctx(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state); + + // Key doesn't exist -> returns default + int val = ctx.get_session_state_or("missing", 99); + assert(val == 99); + + // Key exists -> returns value + ctx.set_session_state("present", 7); + int val2 = ctx.get_session_state_or("present", 99); + assert(val2 == 7); +} + +void test_has_session_state() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + auto state = std::make_shared(); + Context ctx(rm, pm, std::nullopt, std::nullopt, std::nullopt, std::nullopt, state); + + assert(!ctx.has_session_state("key")); + ctx.set_session_state("key", true); + assert(ctx.has_session_state("key")); +} + +void test_no_session_state_returns_empty() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + // Context with no session state (nullptr) + Context ctx(rm, pm); + + // get_session_state returns empty any + auto val = ctx.get_session_state("anything"); + assert(!val.has_value()); + + // has_session_state returns false + assert(!ctx.has_session_state("anything")); + + // get_session_state_or returns default + int def = ctx.get_session_state_or("anything", 42); + assert(def == 42); +} + +void test_set_session_state_without_ptr_throws() +{ + resources::ResourceManager rm; + prompts::PromptManager pm; + + Context ctx(rm, pm); + + bool caught = false; + try + { + ctx.set_session_state("key", 1); + } + catch (const std::runtime_error&) + { + caught = true; + } + assert(caught); +} + +int main() +{ + test_set_and_get_session_state(); + test_shared_session_state_between_contexts(); + test_independent_session_state(); + test_get_session_state_or_default(); + test_has_session_state(); + test_no_session_state_returns_empty(); + test_set_session_state_without_ptr_throws(); + return 0; +} diff --git a/tests/tools/test_tool_manager.cpp b/tests/tools/test_tool_manager.cpp index f32846b..836017b 100644 --- a/tests/tools/test_tool_manager.cpp +++ b/tests/tools/test_tool_manager.cpp @@ -183,7 +183,7 @@ void test_get_nonexistent_throws() { tm.get("nonexistent"); } - catch (const std::out_of_range&) + catch (const NotFoundError&) { threw = true; } @@ -289,7 +289,7 @@ void test_input_schema_for_nonexistent_throws() { tm.input_schema_for("nonexistent"); } - catch (const std::out_of_range&) + catch (const NotFoundError&) { threw = true; } diff --git a/tests/tools/test_tool_sequential.cpp b/tests/tools/test_tool_sequential.cpp new file mode 100644 index 0000000..af446d3 --- /dev/null +++ b/tests/tools/test_tool_sequential.cpp @@ -0,0 +1,85 @@ +/// @file test_tool_sequential.cpp +/// @brief Tests for the sequential tool execution flag + +#include "fastmcpp/app.hpp" +#include "fastmcpp/mcp/handler.hpp" +#include "fastmcpp/tools/tool.hpp" + +#include +#include + +using namespace fastmcpp; + +void test_tool_sequential_flag() +{ + tools::Tool tool("test", + Json{{"type", "object"}, {"properties", Json::object()}}, + Json::object(), + [](const Json&) { return Json{{"ok", true}}; }); + + // Default: not sequential + assert(!tool.sequential()); + + tool.set_sequential(true); + assert(tool.sequential()); + + tool.set_sequential(false); + assert(!tool.sequential()); +} + +void test_fastmcp_tool_registration_sequential() +{ + FastMCP app("test_seq", "1.0.0"); + + FastMCP::ToolOptions opts; + opts.sequential = true; + + app.tool("seq_tool", + Json{{"type", "object"}, + {"properties", {{"x", {{"type", "integer"}}}}}, + {"required", Json::array({"x"})}}, + [](const Json& args) { return args.at("x"); }, opts); + + // Verify the tool info includes execution.concurrency + auto tools_info = app.list_all_tools_info(); + assert(tools_info.size() == 1); + assert(tools_info[0].execution.has_value()); + assert(tools_info[0].execution->is_object()); + assert(tools_info[0].execution->value("concurrency", std::string()) == "sequential"); +} + +void test_handler_reports_sequential_in_listing() +{ + FastMCP app("test_seq_handler", "1.0.0"); + + FastMCP::ToolOptions opts; + opts.sequential = true; + + app.tool("seq_tool", [](const Json&) { return Json{{"ok", true}}; }, opts); + + auto handler = mcp::make_mcp_handler(app); + + // Initialize + Json init = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}}; + handler(init); + + // List tools + Json list = {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "tools/list"}}; + auto resp = handler(list); + assert(resp.contains("result")); + auto& tools = resp["result"]["tools"]; + assert(tools.size() == 1); + + // Check execution.concurrency field + auto& tool_entry = tools[0]; + assert(tool_entry.contains("execution")); + assert(tool_entry["execution"]["concurrency"] == "sequential"); +} + +int main() +{ + test_tool_sequential_flag(); + test_fastmcp_tool_registration_sequential(); + test_handler_reports_sequential_in_listing(); + return 0; +} diff --git a/tests/tools/test_tool_transform_enabled.cpp b/tests/tools/test_tool_transform_enabled.cpp new file mode 100644 index 0000000..a1c6274 --- /dev/null +++ b/tests/tools/test_tool_transform_enabled.cpp @@ -0,0 +1,97 @@ +/// @file test_tool_transform_enabled.cpp +/// @brief Tests for ToolTransformConfig.enabled field + +#include "fastmcpp/providers/local_provider.hpp" +#include "fastmcpp/providers/transforms/tool_transform.hpp" +#include "fastmcpp/tools/tool_transform.hpp" + +#include +#include + +using namespace fastmcpp; +using namespace fastmcpp::tools; + +Tool make_test_tool(const std::string& name) +{ + Json schema = { + {"type", "object"}, + {"properties", {{"x", {{"type", "integer"}}}}}, + {"required", Json::array({"x"})}, + }; + return Tool(name, schema, Json::object(), + [](const Json& args) { return args.at("x").get() * 2; }); +} + +void test_enabled_true_keeps_tool_visible() +{ + auto tool = make_test_tool("visible"); + ToolTransformConfig config; + config.enabled = true; + + auto transformed = config.apply(tool); + assert(!transformed.is_hidden()); +} + +void test_enabled_false_hides_tool() +{ + auto tool = make_test_tool("hidden"); + ToolTransformConfig config; + config.enabled = false; + + auto transformed = config.apply(tool); + assert(transformed.is_hidden()); +} + +void test_enabled_not_set_keeps_default() +{ + auto tool = make_test_tool("default"); + ToolTransformConfig config; + // enabled is std::nullopt by default + + auto transformed = config.apply(tool); + assert(!transformed.is_hidden()); +} + +void test_hidden_tool_filtered_by_provider() +{ + // Create a provider with two tools + auto provider = std::make_shared(); + provider->add_tool(make_test_tool("tool_a")); + provider->add_tool(make_test_tool("tool_b")); + + // Apply transform: disable tool_b via ToolTransform + ToolTransformConfig hide_config; + hide_config.enabled = false; + std::unordered_map transforms; + transforms["tool_b"] = hide_config; + provider->add_transform( + std::make_shared(transforms)); + + auto tools = provider->list_tools_transformed(); + assert(tools.size() == 1); + assert(tools[0].name() == "tool_a"); +} + +void test_hidden_tool_still_invocable() +{ + auto tool = make_test_tool("hidden_invocable"); + ToolTransformConfig config; + config.enabled = false; + + auto transformed = config.apply(tool); + assert(transformed.is_hidden()); + + // But we can still invoke it + auto result = transformed.invoke(Json{{"x", 5}}); + assert(result.get() == 10); +} + +int main() +{ + test_enabled_true_keeps_tool_visible(); + test_enabled_false_hides_tool(); + test_enabled_not_set_keeps_default(); + test_hidden_tool_filtered_by_provider(); + test_hidden_tool_still_invocable(); + return 0; +} diff --git a/tests/transports/ws_streaming.cpp b/tests/transports/ws_streaming.cpp deleted file mode 100644 index fadefcc..0000000 --- a/tests/transports/ws_streaming.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include "fastmcpp/client/transports.hpp" -#include "fastmcpp/util/json.hpp" - -#include -#include -#include - -int main() -{ - const char* url = std::getenv("FASTMCPP_WS_URL"); - if (!url) - { - std::cout << "FASTMCPP_WS_URL not set; skipping WS streaming test.\n"; - return 0; // skip - } - - try - { - fastmcpp::client::WebSocketTransport ws(url); - std::atomic count{0}; - ws.request_stream("", fastmcpp::Json{"ping"}, - [&](const fastmcpp::Json& evt) - { - ++count; - // Print for visibility; require at least one event - std::cout << evt.dump() << "\n"; - }); - if (count.load() == 0) - { - std::cerr << "No WS events received" << std::endl; - return 1; - } - std::cout << "WS streaming received " << count.load() << " events\n"; - return 0; - } - catch (const std::exception& e) - { - std::cerr << "WS streaming test failed: " << e.what() << std::endl; - return 1; - } -} diff --git a/tests/transports/ws_streaming_local.cpp b/tests/transports/ws_streaming_local.cpp deleted file mode 100644 index c273b43..0000000 --- a/tests/transports/ws_streaming_local.cpp +++ /dev/null @@ -1,94 +0,0 @@ -#include "fastmcpp/client/transports.hpp" -#include "fastmcpp/util/json.hpp" - -#include -#include -#include -#include -#include -#include -#include - -int main() -{ - using fastmcpp::Json; - using fastmcpp::client::WebSocketTransport; - - // Start a tiny WebSocket echo/push server on localhost using httplib - httplib::Server svr; - std::atomic got_first_msg{false}; - - svr.set_ws_handler( - "/ws", - // on_open - [&](const httplib::Request& /*req*/, std::shared_ptr /*ws*/) - { - // No-op on open - }, - // on_message - [&](const httplib::Request& /*req*/, std::shared_ptr ws, - const std::string& message, bool /*is_binary*/) - { - (void)message; - got_first_msg = true; - // Push a few JSON frames to the client - ws->send("{\"n\":1}"); - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - ws->send("{\"n\":2}"); - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - ws->send("{\"n\":3}"); - // Close after a moment - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - ws->close(); - }, - // on_close - [&](const httplib::Request& /*req*/, std::shared_ptr /*ws*/, - int /*status*/, const std::string& /*reason*/) {}); - - int port = 18110; - std::thread th([&]() { svr.listen("127.0.0.1", port); }); - svr.wait_until_ready(); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - - std::vector seen; - try - { - WebSocketTransport ws(std::string("ws://127.0.0.1:") + std::to_string(port)); - ws.request_stream("ws", Json{"hello"}, - [&](const Json& evt) - { - if (evt.contains("n")) - seen.push_back(evt["n"].get()); - }); - } - catch (const std::exception& e) - { - std::cerr << "ws stream error: " << e.what() << "\n"; - svr.stop(); - if (th.joinable()) - th.join(); - return 1; - } - - svr.stop(); - if (th.joinable()) - th.join(); - - if (!got_first_msg.load()) - { - std::cerr << "server did not receive client message" << std::endl; - return 1; - } - if (seen.size() != 3) - { - std::cerr << "expected 3 events, got " << seen.size() << "\n"; - return 1; - } - if (seen[0] != 1 || seen[1] != 2 || seen[2] != 3) - { - std::cerr << "unexpected event sequence\n"; - return 1; - } - std::cout << "ok\n"; - return 0; -} From 7e1da43317158f5bf488c08c31c859177ae7e3b3 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sun, 15 Feb 2026 08:51:41 -0800 Subject: [PATCH 2/6] fix: resolve fastmcpp binary relative to generated script on Windows Generated Python CLI scripts now look for the fastmcpp binary next to the script itself before falling back to PATH lookup. Fixes FileNotFoundError on Windows where the binary isn't in PATH. --- src/cli/main.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 1be66a2..fde4779 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -1174,6 +1174,10 @@ static int run_generate_cli_command(int argc, char** argv) script << "DEFAULT_TIMEOUT = " << timeout_seconds << "\n"; script << "AUTH_MODE = " << py_quote(auth_mode) << "\n"; script << "AUTH_ENV = 'FASTMCPP_AUTH_TOKEN'\n\n"; + script << "_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\n"; + script << "_EXE_NAME = 'fastmcpp.exe' if sys.platform == 'win32' else 'fastmcpp'\n"; + script << "_LOCAL_EXE = os.path.join(_SCRIPT_DIR, _EXE_NAME)\n"; + script << "FASTMCPP = _LOCAL_EXE if os.path.isfile(_LOCAL_EXE) else _EXE_NAME\n\n"; script << "def _connection_args():\n"; script << " args = list(CONNECTION)\n"; script << " if AUTH_MODE == 'bearer':\n"; @@ -1184,7 +1188,7 @@ static int run_generate_cli_command(int argc, char** argv) script << " args += ['--header', 'Authorization=Bearer ' + token]\n"; script << " return args\n\n"; script << "def _run(sub_args):\n"; - script << " cmd = ['fastmcpp'] + sub_args + _connection_args()\n"; + script << " cmd = [FASTMCPP] + sub_args + _connection_args()\n"; script << " try:\n"; script << " proc = subprocess.run(cmd, capture_output=True, text=True, timeout=DEFAULT_TIMEOUT)\n"; script << " except subprocess.TimeoutExpired:\n"; From e7220b85d5c0d56ff7327ecf9244f7f79e62c66a Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sun, 15 Feb 2026 10:51:51 -0800 Subject: [PATCH 3/6] style: apply clang-format to all new/modified files --- examples/mcp_apps.cpp | 36 ++-- examples/openapi_provider.cpp | 18 +- examples/response_limiting_middleware.cpp | 10 +- examples/skills_provider.cpp | 8 +- .../fastmcpp/providers/skills_provider.hpp | 63 +++---- .../providers/transforms/prompts_as_tools.hpp | 2 +- .../transforms/resources_as_tools.hpp | 2 +- .../providers/transforms/transform.hpp | 7 +- .../providers/transforms/version_filter.hpp | 3 +- .../server/response_limiting_middleware.hpp | 4 +- include/fastmcpp/types.hpp | 7 +- include/fastmcpp/util/pagination.hpp | 12 +- src/cli/main.cpp | 124 ++++++------ src/mcp/handler.cpp | 124 ++++++------ src/providers/openapi_provider.cpp | 20 +- src/providers/skills_provider.cpp | 86 ++++----- src/providers/transforms/prompts_as_tools.cpp | 20 +- .../transforms/resources_as_tools.cpp | 14 +- src/providers/transforms/version_filter.cpp | 5 +- src/server/ping_middleware.cpp | 7 +- src/server/response_limiting_middleware.cpp | 2 - src/util/json_schema.cpp | 3 +- tests/app/mcp_apps.cpp | 107 ++++++----- tests/cli/generated_cli_e2e.cpp | 176 +++++++++--------- tests/cli/tasks_cli.cpp | 81 ++++---- tests/mcp/test_error_codes.cpp | 11 +- tests/providers/openapi_provider.cpp | 72 ++++--- tests/providers/skills_provider.cpp | 18 +- tests/providers/version_filter.cpp | 10 +- tests/proxy/basic.cpp | 4 +- tests/schema/dereference_toggle.cpp | 38 ++-- tests/server/streamable_http_integration.cpp | 3 +- tests/server/test_response_limiting.cpp | 7 +- tests/server/test_server_session.cpp | 5 +- tests/tools/test_tool_sequential.cpp | 17 +- tests/tools/test_tool_transform_enabled.cpp | 3 +- 36 files changed, 568 insertions(+), 561 deletions(-) diff --git a/examples/mcp_apps.cpp b/examples/mcp_apps.cpp index ddf475c..d2235a7 100644 --- a/examples/mcp_apps.cpp +++ b/examples/mcp_apps.cpp @@ -28,14 +28,15 @@ int main() resource_ui.prefers_border = true; resource_opts.app = resource_ui; - app.resource("ui://widgets/home.html", "Home Widget", - [](const Json&) - { - return fastmcpp::resources::ResourceContent{ - "ui://widgets/home.html", std::nullopt, - std::string{"

Home

"}}; - }, - resource_opts); + app.resource( + "ui://widgets/home.html", "Home Widget", + [](const Json&) + { + return fastmcpp::resources::ResourceContent{ + "ui://widgets/home.html", std::nullopt, + std::string{"

Home

"}}; + }, + resource_opts); // UI resource template with per-template metadata FastMCP::ResourceTemplateOptions templ_opts; @@ -43,15 +44,16 @@ int main() templ_ui.csp = Json{{"connectDomains", Json::array({"https://api.example.test"})}}; templ_opts.app = templ_ui; - app.resource_template("ui://widgets/{name}.html", "Named Widget", - [](const Json& params) - { - const std::string name = params.value("name", "unknown"); - return fastmcpp::resources::ResourceContent{ - "ui://widgets/" + name + ".html", std::nullopt, - std::string{"

" + name + "

"}}; - }, - Json::object(), templ_opts); + app.resource_template( + "ui://widgets/{name}.html", "Named Widget", + [](const Json& params) + { + const std::string name = params.value("name", "unknown"); + return fastmcpp::resources::ResourceContent{ + "ui://widgets/" + name + ".html", std::nullopt, + std::string{"

" + name + "

"}}; + }, + Json::object(), templ_opts); auto handler = fastmcpp::mcp::make_mcp_handler(app); fastmcpp::server::StdioServerWrapper server(handler); diff --git a/examples/openapi_provider.cpp b/examples/openapi_provider.cpp index ba0005d..3c4249f 100644 --- a/examples/openapi_provider.cpp +++ b/examples/openapi_provider.cpp @@ -1,6 +1,7 @@ -#include "fastmcpp/app.hpp" #include "fastmcpp/providers/openapi_provider.hpp" +#include "fastmcpp/app.hpp" + #include #include @@ -16,14 +17,13 @@ int main() spec["paths"]["/status"]["get"] = Json{ {"operationId", "getStatus"}, {"responses", - Json{{"200", - Json{{"description", "ok"}, - {"content", - Json{{"application/json", - Json{{"schema", - Json{{"type", "object"}, - {"properties", - Json{{"status", Json{{"type", "string"}}}}}}}}}}}}}}}, + Json{{"200", Json{{"description", "ok"}, + {"content", + Json{{"application/json", + Json{{"schema", + Json{{"type", "object"}, + {"properties", + Json{{"status", Json{{"type", "string"}}}}}}}}}}}}}}}, }; FastMCP app("openapi-provider-example", "1.0.0"); diff --git a/examples/response_limiting_middleware.cpp b/examples/response_limiting_middleware.cpp index 06bdd15..fdceaf7 100644 --- a/examples/response_limiting_middleware.cpp +++ b/examples/response_limiting_middleware.cpp @@ -1,4 +1,5 @@ #include "fastmcpp/server/response_limiting_middleware.hpp" + #include "fastmcpp/server/server.hpp" #include @@ -23,10 +24,11 @@ int main() server::ResponseLimitingMiddleware limiter(48, "... [truncated]"); srv.add_after(limiter.make_hook()); - Json req = {{"name", "echo_large"}, - {"arguments", - {{"text", - "This response is intentionally long so middleware truncation is easy to see."}}}}; + Json req = { + {"name", "echo_large"}, + {"arguments", + {{"text", + "This response is intentionally long so middleware truncation is easy to see."}}}}; auto resp = srv.handle("tools/call", req); std::cout << resp.dump(2) << "\n"; diff --git a/examples/skills_provider.cpp b/examples/skills_provider.cpp index 6f4ade0..02e5d11 100644 --- a/examples/skills_provider.cpp +++ b/examples/skills_provider.cpp @@ -1,6 +1,7 @@ -#include "fastmcpp/app.hpp" #include "fastmcpp/providers/skills_provider.hpp" +#include "fastmcpp/app.hpp" + #include #include #include @@ -11,8 +12,9 @@ int main() using namespace fastmcpp; FastMCP app("skills-provider-example", "1.0.0"); - auto skills_root = std::filesystem::path(std::getenv("USERPROFILE") ? std::getenv("USERPROFILE") : "") / - ".codex" / "skills"; + auto skills_root = + std::filesystem::path(std::getenv("USERPROFILE") ? std::getenv("USERPROFILE") : "") / + ".codex" / "skills"; try { diff --git a/include/fastmcpp/providers/skills_provider.hpp b/include/fastmcpp/providers/skills_provider.hpp index 955384f..72da832 100644 --- a/include/fastmcpp/providers/skills_provider.hpp +++ b/include/fastmcpp/providers/skills_provider.hpp @@ -54,15 +54,14 @@ class SkillProvider : public Provider class SkillsDirectoryProvider : public Provider { public: - explicit SkillsDirectoryProvider(std::filesystem::path root, bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = - SkillSupportingFiles::Template); + explicit SkillsDirectoryProvider( + std::filesystem::path root, bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); - explicit SkillsDirectoryProvider(std::vector roots, bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = - SkillSupportingFiles::Template); + explicit SkillsDirectoryProvider( + std::vector roots, bool reload = false, + std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); std::vector list_resources() const override; std::optional get_resource(const std::string& uri) const override; @@ -86,65 +85,65 @@ class SkillsDirectoryProvider : public Provider class ClaudeSkillsProvider : public SkillsDirectoryProvider { public: - explicit ClaudeSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit ClaudeSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class CursorSkillsProvider : public SkillsDirectoryProvider { public: - explicit CursorSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit CursorSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class VSCodeSkillsProvider : public SkillsDirectoryProvider { public: - explicit VSCodeSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit VSCodeSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class CodexSkillsProvider : public SkillsDirectoryProvider { public: - explicit CodexSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit CodexSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class GeminiSkillsProvider : public SkillsDirectoryProvider { public: - explicit GeminiSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit GeminiSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class GooseSkillsProvider : public SkillsDirectoryProvider { public: - explicit GooseSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit GooseSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class CopilotSkillsProvider : public SkillsDirectoryProvider { public: - explicit CopilotSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit CopilotSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; class OpenCodeSkillsProvider : public SkillsDirectoryProvider { public: - explicit OpenCodeSkillsProvider(bool reload = false, - std::string main_file_name = "SKILL.md", - SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); + explicit OpenCodeSkillsProvider( + bool reload = false, std::string main_file_name = "SKILL.md", + SkillSupportingFiles supporting_files = SkillSupportingFiles::Template); }; using SkillsProvider = SkillsDirectoryProvider; diff --git a/include/fastmcpp/providers/transforms/prompts_as_tools.hpp b/include/fastmcpp/providers/transforms/prompts_as_tools.hpp index 97a084f..e7d222b 100644 --- a/include/fastmcpp/providers/transforms/prompts_as_tools.hpp +++ b/include/fastmcpp/providers/transforms/prompts_as_tools.hpp @@ -20,7 +20,7 @@ class PromptsAsTools : public Transform std::vector list_tools(const ListToolsNext& call_next) const override; std::optional get_tool(const std::string& name, - const GetToolNext& call_next) const override; + const GetToolNext& call_next) const override; void set_provider(const Provider* provider) { diff --git a/include/fastmcpp/providers/transforms/resources_as_tools.hpp b/include/fastmcpp/providers/transforms/resources_as_tools.hpp index cd288ed..1ca0bb3 100644 --- a/include/fastmcpp/providers/transforms/resources_as_tools.hpp +++ b/include/fastmcpp/providers/transforms/resources_as_tools.hpp @@ -23,7 +23,7 @@ class ResourcesAsTools : public Transform std::vector list_tools(const ListToolsNext& call_next) const override; std::optional get_tool(const std::string& name, - const GetToolNext& call_next) const override; + const GetToolNext& call_next) const override; void set_provider(const Provider* provider) { diff --git a/include/fastmcpp/providers/transforms/transform.hpp b/include/fastmcpp/providers/transforms/transform.hpp index 4913879..baa54db 100644 --- a/include/fastmcpp/providers/transforms/transform.hpp +++ b/include/fastmcpp/providers/transforms/transform.hpp @@ -48,8 +48,8 @@ class Transform return call_next(); } - virtual std::optional - get_resource(const std::string& uri, const GetResourceNext& call_next) const + virtual std::optional get_resource(const std::string& uri, + const GetResourceNext& call_next) const { return call_next(uri); } @@ -61,8 +61,7 @@ class Transform } virtual std::optional - get_resource_template(const std::string& uri, - const GetResourceTemplateNext& call_next) const + get_resource_template(const std::string& uri, const GetResourceTemplateNext& call_next) const { return call_next(uri); } diff --git a/include/fastmcpp/providers/transforms/version_filter.hpp b/include/fastmcpp/providers/transforms/version_filter.hpp index 93aaba2..2e2abc0 100644 --- a/include/fastmcpp/providers/transforms/version_filter.hpp +++ b/include/fastmcpp/providers/transforms/version_filter.hpp @@ -19,7 +19,8 @@ class VersionFilter : public Transform std::optional get_tool(const std::string& name, const GetToolNext& call_next) const override; - std::vector list_resources(const ListResourcesNext& call_next) const override; + std::vector + list_resources(const ListResourcesNext& call_next) const override; std::optional get_resource(const std::string& uri, const GetResourceNext& call_next) const override; diff --git a/include/fastmcpp/server/response_limiting_middleware.hpp b/include/fastmcpp/server/response_limiting_middleware.hpp index a6ce75a..464724d 100644 --- a/include/fastmcpp/server/response_limiting_middleware.hpp +++ b/include/fastmcpp/server/response_limiting_middleware.hpp @@ -15,8 +15,8 @@ class ResponseLimitingMiddleware { public: explicit ResponseLimitingMiddleware(size_t max_size = 1'000'000, - std::string truncation_suffix = "... [truncated]", - std::vector tool_filter = {}); + std::string truncation_suffix = "... [truncated]", + std::vector tool_filter = {}); /// Returns an AfterHook that truncates tools/call responses AfterHook make_hook() const; diff --git a/include/fastmcpp/types.hpp b/include/fastmcpp/types.hpp index 2f32938..36c2999 100644 --- a/include/fastmcpp/types.hpp +++ b/include/fastmcpp/types.hpp @@ -70,8 +70,8 @@ struct AppConfig bool empty() const { - return !resource_uri && !visibility && !csp && !permissions && !domain && - !prefers_border && (extra.is_null() || extra.empty()); + return !resource_uri && !visibility && !csp && !permissions && !domain && !prefers_border && + (extra.is_null() || extra.empty()); } }; @@ -148,8 +148,7 @@ inline void from_json(const Json& j, AppConfig& app) for (const auto& [k, v] : j.items()) { if (k == "resource_uri" || k == "visibility" || k == "csp" || k == "permissions" || - k == "domain" || k == "prefers_border" || k == "resourceUri" || - k == "prefersBorder") + k == "domain" || k == "prefers_border" || k == "resourceUri" || k == "prefersBorder") continue; app.extra[k] = v; } diff --git a/include/fastmcpp/util/pagination.hpp b/include/fastmcpp/util/pagination.hpp index 7aab8c9..22a910d 100644 --- a/include/fastmcpp/util/pagination.hpp +++ b/include/fastmcpp/util/pagination.hpp @@ -40,12 +40,12 @@ inline std::string base64_encode(const std::string& input) inline std::string base64_decode(const std::string& input) { static const int b64_table[] = { - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, - -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, - 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, - 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51}; + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, + -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, + 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51}; std::string result; result.reserve(input.size() * 3 / 4); diff --git a/src/cli/main.cpp b/src/cli/main.cpp index fde4779..47bc276 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -5,8 +5,8 @@ #include "fastmcpp/server/server.hpp" #include "fastmcpp/version.hpp" -#include #include +#include #include #include #include @@ -55,7 +55,8 @@ static void print_connection_options() std::cout << " --mcp-path Override MCP path for streamable HTTP\n"; std::cout << " --stdio Spawn an MCP stdio server\n"; std::cout << " --stdio-arg Repeatable args for --stdio\n"; - std::cout << " --stdio-one-shot Spawn a fresh process per request (disables keep-alive)\n"; + std::cout << " --stdio-one-shot Spawn a fresh process per request (disables " + "keep-alive)\n"; std::cout << " --header Repeatable header for HTTP/streamable-http\n"; } @@ -67,10 +68,14 @@ static int usage(int exit_code = 1) std::cout << " fastmcpp --help\n"; std::cout << " fastmcpp client sum
\n"; std::cout << " fastmcpp discover [connection options] [--pretty]\n"; - std::cout << " fastmcpp list [connection options] [--pretty]\n"; + std::cout << " fastmcpp list [connection " + "options] [--pretty]\n"; std::cout << " fastmcpp call [--args ] [connection options] [--pretty]\n"; - std::cout << " fastmcpp generate-cli [output] [--force] [--timeout ] [--auth ] [--header ] [--no-skill]\n"; - std::cout << " fastmcpp install [server_spec]\n"; + std::cout << " fastmcpp generate-cli [output] [--force] [--timeout ] " + "[--auth ] [--header ] [--no-skill]\n"; + std::cout + << " fastmcpp install " + "[server_spec]\n"; std::cout << " fastmcpp tasks --help\n"; std::cout << "\n"; print_connection_options(); @@ -83,16 +88,19 @@ static int tasks_usage(int exit_code = 1) 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 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 << " fastmcpp tasks result [connection options] [--wait] [--timeout-ms " + "] [--pretty]\n"; std::cout << "\n"; print_connection_options(); std::cout << "\n"; std::cout << "Notes:\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 << " - 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; } @@ -101,7 +109,10 @@ static int install_usage(int exit_code = 1) { std::cout << "fastmcpp install\n"; std::cout << "Usage:\n"; - std::cout << " fastmcpp install [--name ] [--command ] [--arg ] [--with ] [--with-editable ] [--python ] [--with-requirements ] [--project ] [--env KEY=VALUE] [--env-file ] [--workspace ] [--copy]\n"; + std::cout << " fastmcpp install [--name ] [--command " + "] [--arg ] [--with ] [--with-editable ] [--python ] " + "[--with-requirements ] [--project ] [--env KEY=VALUE] [--env-file " + "] [--workspace ] [--copy]\n"; std::cout << "Targets:\n"; std::cout << " stdio Print stdio launch command\n"; std::cout << " mcp-json Print MCP JSON entry (\"name\": {command,args,env})\n"; @@ -157,7 +168,7 @@ static bool consume_flag(std::vector& args, const std::string& flag } static std::vector consume_all_flag_values(std::vector& args, - const std::string& flag) + const std::string& flag) { std::vector values; while (true) @@ -286,8 +297,8 @@ static fastmcpp::client::Client make_client_from_connection(const Connection& co return Client(std::make_unique(conn.url_or_command, std::chrono::seconds(300), headers)); case Connection::Kind::StreamableHttp: - return Client(std::make_unique(conn.url_or_command, conn.mcp_path, - headers)); + return Client( + std::make_unique(conn.url_or_command, conn.mcp_path, headers)); case Connection::Kind::Stdio: return Client(std::make_unique(conn.url_or_command, conn.stdio_args, std::nullopt, conn.stdio_keep_alive)); @@ -473,7 +484,8 @@ static int run_tasks_command(int argc, char** argv) if (sub == "cancel") { auto client = make_client_from_connection(*conn); - fastmcpp::Json res = client.call("tasks/cancel", fastmcpp::Json{{"taskId", task_id}}); + fastmcpp::Json res = + client.call("tasks/cancel", fastmcpp::Json{{"taskId", task_id}}); dump_json(res, pretty); return 0; } @@ -484,7 +496,8 @@ static int run_tasks_command(int argc, char** argv) auto start = std::chrono::steady_clock::now(); while (true) { - fastmcpp::Json status = client.call("tasks/get", fastmcpp::Json{{"taskId", task_id}}); + fastmcpp::Json status = + client.call("tasks/get", fastmcpp::Json{{"taskId", task_id}}); std::string s = status.value("status", ""); if (s == "completed") break; @@ -493,8 +506,8 @@ static int run_tasks_command(int argc, char** argv) dump_json(status, pretty); return 3; } - if (timeout_ms > 0 && - std::chrono::steady_clock::now() - start >= std::chrono::milliseconds(timeout_ms)) + if (timeout_ms > 0 && std::chrono::steady_clock::now() - start >= + std::chrono::milliseconds(timeout_ms)) { dump_json(status, pretty); return 4; @@ -582,7 +595,8 @@ static int run_list_command(int argc, char** argv) std::vector args = collect_args(argc, argv, 2); if (consume_flag(args, "--help") || consume_flag(args, "-h") || args.empty()) { - std::cout << "Usage: fastmcpp list [connection options] [--pretty]\n"; + std::cout << "Usage: fastmcpp list " + "[connection options] [--pretty]\n"; return args.empty() ? 1 : 0; } @@ -637,7 +651,8 @@ static int run_call_command(int argc, char** argv) std::vector args = collect_args(argc, argv, 2); if (consume_flag(args, "--help") || consume_flag(args, "-h")) { - std::cout << "Usage: fastmcpp call [--args ] [connection options] [--pretty]\n"; + std::cout + << "Usage: fastmcpp call [--args ] [connection options] [--pretty]\n"; return 0; } if (args.empty()) @@ -685,8 +700,8 @@ static int run_call_command(int argc, char** argv) { auto client = make_client_from_connection(*conn); initialize_client(client); - fastmcpp::Json result = - client.call("tools/call", fastmcpp::Json{{"name", tool_name}, {"arguments", parsed_args}}); + fastmcpp::Json result = client.call( + "tools/call", fastmcpp::Json{{"name", tool_name}, {"arguments", parsed_args}}); dump_json(result, pretty); return 0; } @@ -701,12 +716,10 @@ static std::string ps_quote(const std::string& s) { std::string out = "'"; for (char c : s) - { if (c == '\'') out += "''"; else out.push_back(c); - } out.push_back('\''); return out; } @@ -728,13 +741,10 @@ static std::string sanitize_ps_function_name(const std::string& name) std::string out; out.reserve(name.size()); for (char c : name) - { - if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '_') + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') out.push_back(c); else out.push_back('_'); - } if (out.empty()) out = "tool"; if (out.front() >= '0' && out.front() <= '9') @@ -749,9 +759,9 @@ static std::string url_encode(const std::string& value) out.reserve(value.size() * 3); for (unsigned char c : value) { - const bool unreserved = - (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '-' || c == '_' || c == '.' || c == '~'; + const bool unreserved = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || + c == '~'; if (unreserved) { out.push_back(static_cast(c)); @@ -766,8 +776,7 @@ static std::string url_encode(const std::string& value) static std::string base64_urlsafe_encode(const std::string& input) { - static const char* kB64 = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + static const char* kB64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; out.reserve(((input.size() + 2) / 3) * 4); @@ -786,12 +795,10 @@ static std::string base64_urlsafe_encode(const std::string& input) } for (char& c : out) - { if (c == '+') c = '-'; else if (c == '/') c = '_'; - } return out; } @@ -816,34 +823,29 @@ static std::string shell_quote(const std::string& value) std::string out = "\""; for (char c : value) - { if (c == '"') out += "\\\""; else out.push_back(c); - } out.push_back('"'); return out; } static bool starts_with(const std::string& value, const std::string& prefix) { - return value.size() >= prefix.size() && - value.compare(0, prefix.size(), prefix) == 0; + return value.size() >= prefix.size() && value.compare(0, prefix.size(), prefix) == 0; } static std::string py_quote(const std::string& s) { std::string out = "'"; for (char c : s) - { if (c == '\\') out += "\\\\"; else if (c == '\'') out += "\\'"; else out.push_back(c); - } out.push_back('\''); return out; } @@ -885,8 +887,7 @@ static std::string derive_server_name(const std::string& server_spec) if (server_spec.size() >= 3) { auto pos = server_spec.find(':'); - if (pos != std::string::npos && pos > 0 && - server_spec.find('/') == std::string::npos && + if (pos != std::string::npos && pos > 0 && server_spec.find('/') == std::string::npos && server_spec.find('\\') == std::string::npos) { auto suffix = server_spec.substr(pos + 1); @@ -952,17 +953,16 @@ static std::string build_tool_args_example(const fastmcpp::Json& tool) { fastmcpp::Json args = fastmcpp::Json::object(); if (!(tool.contains("inputSchema") && tool["inputSchema"].is_object() && - tool["inputSchema"].contains("properties") && tool["inputSchema"]["properties"].is_object())) + tool["inputSchema"].contains("properties") && + tool["inputSchema"]["properties"].is_object())) return "{}"; std::unordered_set required; if (tool["inputSchema"].contains("required") && tool["inputSchema"]["required"].is_array()) { for (const auto& entry : tool["inputSchema"]["required"]) - { if (entry.is_string()) required.insert(entry.get()); - } } for (const auto& [prop_name, prop_schema] : tool["inputSchema"]["properties"].items()) @@ -991,8 +991,8 @@ static std::optional connection_from_server_spec(const std::string& if (std::regex_match(server_spec, m, re)) { c.url_or_command = m[1].str(); - c.mcp_path = (m.size() >= 3 && m[2].matched && !m[2].str().empty()) ? m[2].str() - : "/mcp"; + c.mcp_path = + (m.size() >= 3 && m[2].matched && !m[2].str().empty()) ? m[2].str() : "/mcp"; } else { @@ -1014,7 +1014,8 @@ static int run_generate_cli_command(int argc, char** argv) std::vector args = collect_args(argc, argv, 2); if (consume_flag(args, "--help") || consume_flag(args, "-h")) { - std::cout << "Usage: fastmcpp generate-cli [output] [--force] [--timeout ] [--auth ] [--header ] [--no-skill]\n"; + std::cout << "Usage: fastmcpp generate-cli [output] [--force] [--timeout " + "] [--auth ] [--header ] [--no-skill]\n"; return 0; } @@ -1076,7 +1077,8 @@ static int run_generate_cli_command(int argc, char** argv) } if (args.size() == 1) { - // Backward-compat: explicit connection flags may use the remaining positional as output. + // Backward-compat: explicit connection flags may use the remaining positional as + // output. if (output_path) { std::cerr << "Output provided both positionally and via --output\n"; @@ -1090,7 +1092,8 @@ static int run_generate_cli_command(int argc, char** argv) { if (args.empty()) { - std::cerr << "Missing server_spec. Usage: fastmcpp generate-cli [output]\n"; + std::cerr + << "Missing server_spec. Usage: fastmcpp generate-cli [output]\n"; return 2; } server_spec = args.front(); @@ -1128,8 +1131,8 @@ static int run_generate_cli_command(int argc, char** argv) } if (!no_skill && std::filesystem::exists(skill_file) && !force) { - std::cerr << "Skill file already exists. Use --force to overwrite: " - << skill_file.string() << "\n"; + std::cerr << "Skill file already exists. Use --force to overwrite: " << skill_file.string() + << "\n"; return 2; } @@ -1146,10 +1149,8 @@ static int run_generate_cli_command(int argc, char** argv) if (tools_result.contains("tools") && tools_result["tools"].is_array()) { for (const auto& tool : tools_result["tools"]) - { if (tool.is_object() && tool.contains("name") && tool["name"].is_string()) discovered_tools.push_back(tool); - } } } catch (const std::exception& e) @@ -1190,7 +1191,8 @@ static int run_generate_cli_command(int argc, char** argv) script << "def _run(sub_args):\n"; script << " cmd = [FASTMCPP] + sub_args + _connection_args()\n"; script << " try:\n"; - script << " proc = subprocess.run(cmd, capture_output=True, text=True, timeout=DEFAULT_TIMEOUT)\n"; + script << " proc = subprocess.run(cmd, capture_output=True, text=True, " + "timeout=DEFAULT_TIMEOUT)\n"; script << " except subprocess.TimeoutExpired:\n"; script << " print(f'Command timed out after {DEFAULT_TIMEOUT}s', file=sys.stderr)\n"; script << " raise SystemExit(124)\n"; @@ -1272,10 +1274,8 @@ static int run_generate_cli_command(int argc, char** argv) skill << "## Utility Commands\n\n"; skill << "```bash\n"; - skill << "uv run --with fastmcp python " << out_file.filename().string() - << " discover\n"; - skill << "uv run --with fastmcp python " << out_file.filename().string() - << " list-tools\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() << " discover\n"; + skill << "uv run --with fastmcp python " << out_file.filename().string() << " list-tools\n"; skill << "uv run --with fastmcp python " << out_file.filename().string() << " list-resources\n"; skill << "uv run --with fastmcp python " << out_file.filename().string() @@ -1349,7 +1349,8 @@ static bool load_env_file_into(const std::filesystem::path& env_file, fastmcpp:: return true; } -static fastmcpp::Json build_stdio_install_config(const std::string& name, const std::string& command, +static fastmcpp::Json build_stdio_install_config(const std::string& name, + const std::string& command, const std::vector& command_args, const fastmcpp::Json& env) { @@ -1428,7 +1429,8 @@ struct InstallLaunchSpec static InstallLaunchSpec build_launch_from_server_spec( const std::string& server_spec, const std::vector& with_packages, const std::vector& with_editable, const std::optional& python_version, - const std::optional& requirements_file, const std::optional& project_dir) + const std::optional& requirements_file, + const std::optional& project_dir) { InstallLaunchSpec spec; spec.command = "uv"; @@ -1563,9 +1565,7 @@ static int run_install_command(int argc, char** argv) fastmcpp::Json server_config = config["mcpServers"][server_name]; if (target == "stdio") - { return emit_install_output(build_stdio_command_line(command, command_args), copy_mode); - } if (target == "mcp-json") { diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index 684c0a1..988e5e6 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -32,18 +32,17 @@ namespace fastmcpp::mcp static constexpr int kJsonRpcMethodNotFound = -32601; static constexpr int kJsonRpcInvalidParams = -32602; static constexpr int kJsonRpcInternalError = -32603; -static constexpr int kMcpMethodNotFound = -32001; // MCP "Method not found" -static constexpr int kMcpResourceNotFound = -32002; // MCP "Resource not found" +static constexpr int kMcpMethodNotFound = -32001; // MCP "Method not found" +static constexpr int kMcpResourceNotFound = -32002; // MCP "Resource not found" static constexpr int kMcpToolTimeout = -32000; static constexpr const char* kUiExtensionId = "io.modelcontextprotocol/ui"; // Helper: create fastmcp metadata namespace (parity with Python fastmcp 53e220a9) static fastmcpp::Json make_fastmcp_meta() { - return fastmcpp::Json{ - {"version", std::to_string(fastmcpp::VERSION_MAJOR) + "." + - std::to_string(fastmcpp::VERSION_MINOR) + "." + - std::to_string(fastmcpp::VERSION_PATCH)}}; + return fastmcpp::Json{{"version", std::to_string(fastmcpp::VERSION_MAJOR) + "." + + std::to_string(fastmcpp::VERSION_MINOR) + "." + + std::to_string(fastmcpp::VERSION_PATCH)}}; } static fastmcpp::Json merge_meta_with_ui(const std::optional& meta, @@ -71,7 +70,7 @@ static std::string normalize_resource_uri(std::string uri) } static std::optional find_resource_app_config(const FastMCP& app, - const std::string& uri) + const std::string& uri) { const std::string normalized = normalize_resource_uri(uri); for (const auto& resource : app.list_all_resources()) @@ -98,10 +97,9 @@ static void attach_resource_content_meta_ui(fastmcpp::Json& content_json, const auto app_cfg = find_resource_app_config(app, request_uri); if (!app_cfg) return; - fastmcpp::Json meta = - content_json.contains("_meta") && content_json["_meta"].is_object() - ? content_json["_meta"] - : fastmcpp::Json::object(); + fastmcpp::Json meta = content_json.contains("_meta") && content_json["_meta"].is_object() + ? content_json["_meta"] + : fastmcpp::Json::object(); meta["ui"] = *app_cfg; if (!meta.empty()) content_json["_meta"] = std::move(meta); @@ -213,16 +211,14 @@ static fastmcpp::Json normalize_output_schema_for_mcp(const fastmcpp::Json& sche }; } -static fastmcpp::Json -make_tool_entry(const std::string& name, const std::string& description, - const fastmcpp::Json& schema, - const std::optional& title = std::nullopt, - const std::optional>& icons = std::nullopt, - const fastmcpp::Json& output_schema = fastmcpp::Json(), - fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden, - bool sequential = false, - const std::optional& app = std::nullopt, - const std::optional& meta = std::nullopt) +static fastmcpp::Json make_tool_entry( + const std::string& name, const std::string& description, const fastmcpp::Json& schema, + const std::optional& title = std::nullopt, + const std::optional>& icons = std::nullopt, + const fastmcpp::Json& output_schema = fastmcpp::Json(), + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden, bool sequential = false, + const std::optional& app = std::nullopt, + const std::optional& meta = std::nullopt) { fastmcpp::Json entry = { {"name", name}, @@ -974,10 +970,9 @@ make_mcp_handler(const std::string& server_name, const std::string& version, else if (tool.description()) desc = *tool.description(); - tools_array.push_back( - make_tool_entry(name, desc, schema, tool.title(), tool.icons(), - tool.output_schema(), tool.task_support(), - tool.sequential(), tool.app())); + tools_array.push_back(make_tool_entry( + name, desc, schema, tool.title(), tool.icons(), tool.output_schema(), + tool.task_support(), tool.sequential(), tool.app())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -1060,7 +1055,8 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } @@ -1265,7 +1261,8 @@ std::function make_mcp_handler( } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } @@ -1346,10 +1343,9 @@ make_mcp_handler(const std::string& server_name, const std::string& version, { const auto& tool = tools.get(name); std::string desc = tool.description() ? *tool.description() : ""; - tools_array.push_back( - make_tool_entry(name, desc, tool.input_schema(), tool.title(), - tool.icons(), tool.output_schema(), tool.task_support(), - tool.sequential(), tool.app())); + tools_array.push_back(make_tool_entry( + name, desc, tool.input_schema(), tool.title(), tool.icons(), + tool.output_schema(), tool.task_support(), tool.sequential(), tool.app())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1453,11 +1449,13 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } } - return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, + std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } @@ -1525,10 +1523,9 @@ make_mcp_handler(const std::string& server_name, const std::string& version, { const auto& tool = tools.get(name); std::string desc = tool.description() ? *tool.description() : ""; - tools_array.push_back( - make_tool_entry(name, desc, tool.input_schema(), tool.title(), tool.icons(), - tool.output_schema(), tool.task_support(), - tool.sequential(), tool.app())); + tools_array.push_back(make_tool_entry( + name, desc, tool.input_schema(), tool.title(), tool.icons(), + tool.output_schema(), tool.task_support(), tool.sequential(), tool.app())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -1775,11 +1772,13 @@ make_mcp_handler(const std::string& server_name, const std::string& version, } } - return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, + std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } @@ -1887,7 +1886,8 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(tools_array, "tools", params, app.list_page_size())}}; + {"result", apply_pagination(tools_array, "tools", params, + app.list_page_size())}}; } if (method == "tools/call") @@ -2050,8 +2050,9 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) if (q.state == TaskRegistry::ResultState::NotReady) return jsonrpc_error(id, kJsonRpcInvalidParams, "Task not completed"); if (q.state == TaskRegistry::ResultState::Cancelled) - return jsonrpc_error( - id, kJsonRpcInternalError, q.error_message.empty() ? "Task cancelled" : q.error_message); + return jsonrpc_error(id, kJsonRpcInternalError, + q.error_message.empty() ? "Task cancelled" + : q.error_message); if (q.state == TaskRegistry::ResultState::Failed) return jsonrpc_error(id, kJsonRpcInternalError, q.error_message.empty() ? "Task failed" : q.error_message); @@ -2153,7 +2154,8 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(resources_array, "resources", params, app.list_page_size())}}; + {"result", apply_pagination(resources_array, "resources", + params, app.list_page_size())}}; } if (method == "resources/templates/list") @@ -2193,7 +2195,8 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) return fastmcpp::Json{ {"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(templates_array, "resourceTemplates", params, app.list_page_size())}}; + {"result", apply_pagination(templates_array, "resourceTemplates", params, + app.list_page_size())}}; } if (method == "resources/read") @@ -2382,7 +2385,8 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(prompts_array, "prompts", params, app.list_page_size())}}; + {"result", apply_pagination(prompts_array, "prompts", params, + app.list_page_size())}}; } if (method == "prompts/get") @@ -2494,11 +2498,13 @@ make_mcp_handler(const FastMCP& app, SessionAccessor session_accessor) } } - return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, + std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } @@ -2876,11 +2882,13 @@ std::function make_mcp_handler(const Prox } } - return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, + std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } @@ -3039,7 +3047,8 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(tools_array, "tools", params, app.list_page_size())}}; + {"result", apply_pagination(tools_array, "tools", params, + app.list_page_size())}}; } if (method == "tools/call") @@ -3133,7 +3142,8 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(resources_array, "resources", params, app.list_page_size())}}; + {"result", apply_pagination(resources_array, "resources", + params, app.list_page_size())}}; } if (method == "resources/templates/list") @@ -3173,7 +3183,8 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces return fastmcpp::Json{ {"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(templates_array, "resourceTemplates", params, app.list_page_size())}}; + {"result", apply_pagination(templates_array, "resourceTemplates", params, + app.list_page_size())}}; } if (method == "resources/read") @@ -3267,7 +3278,8 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, - {"result", apply_pagination(prompts_array, "prompts", params, app.list_page_size())}}; + {"result", apply_pagination(prompts_array, "prompts", params, + app.list_page_size())}}; } if (method == "prompts/get") @@ -3319,11 +3331,13 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces } } - return jsonrpc_error(id, kJsonRpcMethodNotFound, std::string("Method '") + method + "' not found"); + return jsonrpc_error(id, kJsonRpcMethodNotFound, + std::string("Method '") + method + "' not found"); } catch (const std::exception& e) { - return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, e.what()); + return jsonrpc_error(message.value("id", fastmcpp::Json()), kJsonRpcInternalError, + e.what()); } }; } diff --git a/src/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp index 2322b52..8724130 100644 --- a/src/providers/openapi_provider.cpp +++ b/src/providers/openapi_provider.cpp @@ -2,12 +2,11 @@ #include "fastmcpp/exceptions.hpp" -#include - #include #include #include #include +#include #include #include #include @@ -55,9 +54,9 @@ std::string url_encode_component(const std::string& value) out.reserve(value.size() * 3); for (unsigned char c : value) { - const bool unreserved = - (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '-' || c == '_' || c == '.' || c == '~'; + const bool unreserved = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || + c == '~'; if (unreserved) { out.push_back(static_cast(c)); @@ -123,8 +122,7 @@ OpenAPIProvider::OpenAPIProvider(Json openapi_spec, std::optional b } OpenAPIProvider OpenAPIProvider::from_file(const std::string& file_path, - std::optional base_url, - Options options) + std::optional base_url, Options options) { std::ifstream in(std::filesystem::path(file_path), std::ios::binary); if (!in) @@ -246,8 +244,9 @@ std::vector OpenAPIProvider::parse_routes() co return; for (const auto& param : params) { - if (!param.is_object() || !param.contains("name") || !param["name"].is_string() || - !param.contains("in") || !param["in"].is_string()) + if (!param.is_object() || !param.contains("name") || + !param["name"].is_string() || !param.contains("in") || + !param["in"].is_string()) continue; const std::string param_name = param["name"].get(); @@ -351,8 +350,7 @@ std::vector OpenAPIProvider::parse_routes() co } if (!options_.validate_output && !route.output_schema.is_null()) - route.output_schema = - Json{{"type", "object"}, {"additionalProperties", true}}; + route.output_schema = Json{{"type", "object"}, {"additionalProperties", true}}; routes.push_back(std::move(route)); } diff --git a/src/providers/skills_provider.cpp b/src/providers/skills_provider.cpp index 195480b..1bbefe7 100644 --- a/src/providers/skills_provider.cpp +++ b/src/providers/skills_provider.cpp @@ -29,10 +29,9 @@ bool is_text_extension(const std::filesystem::path& path) { const auto ext = path.extension().string(); static const std::unordered_set kTextExt = { - ".txt", ".md", ".markdown", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", - ".conf", ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".py", - ".cpp", ".hpp", ".c", ".h", ".rs", ".go", ".java", ".sh", ".ps1", - ".sql", ".log", + ".txt", ".md", ".markdown", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", + ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".py", ".cpp", ".hpp", + ".c", ".h", ".rs", ".go", ".java", ".sh", ".ps1", ".sql", ".log", }; return kTextExt.find(ext) != kTextExt.end(); } @@ -57,7 +56,8 @@ std::string compute_file_hash(const std::filesystem::path& path) if (!in) return "sha256:"; - std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::vector bytes((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); auto rotr = [](uint32_t x, uint32_t n) -> uint32_t { return (x >> n) | (x << (32 - n)); }; auto ch = [](uint32_t x, uint32_t y, uint32_t z) -> uint32_t { return (x & y) ^ (~x & z); }; @@ -69,17 +69,16 @@ std::string compute_file_hash(const std::filesystem::path& path) auto ssig1 = [&](uint32_t x) -> uint32_t { return rotr(x, 17) ^ rotr(x, 19) ^ (x >> 10); }; static constexpr std::array k = { - 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, 0x59f111f1U, - 0x923f82a4U, 0xab1c5ed5U, 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, - 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, 0xc19bf174U, 0xe49b69c1U, 0xefbe4786U, - 0x0fc19dc6U, 0x240ca1ccU, 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, - 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, - 0x06ca6351U, 0x14292967U, 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, 0x53380d13U, - 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, 0xa2bfe8a1U, 0xa81a664bU, - 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, - 0x19a4c116U, 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, - 0x5b9cca4fU, 0x682e6ff3U, 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, - 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U, + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, 0x59f111f1U, 0x923f82a4U, + 0xab1c5ed5U, 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, 0x72be5d74U, 0x80deb1feU, + 0x9bdc06a7U, 0xc19bf174U, 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, 0x2de92c6fU, + 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, + 0xc6e00bf3U, 0xd5a79147U, 0x06ca6351U, 0x14292967U, 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, + 0x53380d13U, 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, 0xa2bfe8a1U, 0xa81a664bU, + 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, 0x19a4c116U, + 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, 0x682e6ff3U, + 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, + 0xc67178f2U, }; uint64_t bit_len = static_cast(bytes.size()) * 8ULL; @@ -106,8 +105,7 @@ std::string compute_file_hash(const std::filesystem::path& path) const size_t j = offset + i * 4; w[i] = (static_cast(bytes[j]) << 24) | (static_cast(bytes[j + 1]) << 16) | - (static_cast(bytes[j + 2]) << 8) | - static_cast(bytes[j + 3]); + (static_cast(bytes[j + 2]) << 8) | static_cast(bytes[j + 3]); } for (size_t i = 16; i < 64; ++i) w[i] = ssig1(w[i - 2]) + w[i - 7] + ssig0(w[i - 15]) + w[i - 16]; @@ -146,9 +144,8 @@ std::string compute_file_hash(const std::filesystem::path& path) std::string trim_copy(std::string value) { - value.erase(value.begin(), - std::find_if(value.begin(), value.end(), - [](unsigned char ch) { return !std::isspace(ch); })); + value.erase(value.begin(), std::find_if(value.begin(), value.end(), + [](unsigned char ch) { return !std::isspace(ch); })); value.erase(std::find_if(value.rbegin(), value.rend(), [](unsigned char ch) { return !std::isspace(ch); }) .base(), @@ -197,7 +194,8 @@ bool is_within(const std::filesystem::path& root, const std::filesystem::path& c return candidate_text.size() == root_text.size() || candidate_text[root_text.size()] == '/'; } -resources::ResourceContent read_file_content(const std::filesystem::path& path, const std::string& uri) +resources::ResourceContent read_file_content(const std::filesystem::path& path, + const std::string& uri) { if (!std::filesystem::exists(path) || !std::filesystem::is_regular_file(path)) throw NotFoundError("Skill file not found: " + path.string()); @@ -216,7 +214,8 @@ resources::ResourceContent read_file_content(const std::filesystem::path& path, } std::ifstream in(path, std::ios::binary); - std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::vector bytes((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); content.data = std::move(bytes); return content; } @@ -258,10 +257,8 @@ std::vector SkillProvider::list_files() const { std::vector files; for (const auto& entry : std::filesystem::recursive_directory_iterator(skill_path_)) - { if (entry.is_regular_file()) files.push_back(entry.path()); - } return files; } @@ -322,8 +319,8 @@ std::vector SkillProvider::list_resources() const main_file.name = skill_name_ + "/" + main_file_name_; main_file.description = description; main_file.mime_type = "text/markdown"; - main_file.provider = [main_path = skill_path_ / main_file_name_, uri = main_file.uri](const Json&) - { return read_file_content(main_path, uri); }; + main_file.provider = [main_path = skill_path_ / main_file_name_, uri = main_file.uri]( + const Json&) { return read_file_content(main_path, uri); }; out.push_back(main_file); resources::Resource manifest; @@ -394,7 +391,8 @@ std::vector SkillProvider::list_resource_templates( if (!is_within(root, full)) throw ValidationError("Skill path escapes root: " + rel); - const std::string uri = "skill://" + skill_name + "/" + to_uri_path(std::filesystem::relative(full, root)); + const std::string uri = + "skill://" + skill_name + "/" + to_uri_path(std::filesystem::relative(full, root)); return read_file_content(full, uri); }; templ.parse(); @@ -410,8 +408,8 @@ SkillProvider::get_resource_template(const std::string& uri) const return std::nullopt; } -SkillsDirectoryProvider::SkillsDirectoryProvider(std::vector roots, bool reload, - std::string main_file_name, +SkillsDirectoryProvider::SkillsDirectoryProvider(std::vector roots, + bool reload, std::string main_file_name, SkillSupportingFiles supporting_files) : roots_(std::move(roots)), reload_(reload), main_file_name_(std::move(main_file_name)), supporting_files_(supporting_files) @@ -460,8 +458,8 @@ void SkillsDirectoryProvider::discover_skills() const try { - providers_.push_back(std::make_shared( - skill_dir, main_file_name_, supporting_files_)); + providers_.push_back( + std::make_shared(skill_dir, main_file_name_, supporting_files_)); } catch (...) { @@ -481,10 +479,8 @@ std::vector SkillsDirectoryProvider::list_resources() const for (const auto& provider : providers_) { for (const auto& resource : provider->list_resources()) - { if (seen.insert(resource.uri).second) out.push_back(resource); - } } return out; } @@ -510,10 +506,8 @@ std::vector SkillsDirectoryProvider::list_resource_ for (const auto& provider : providers_) { for (const auto& templ : provider->list_resource_templates()) - { if (seen.insert(templ.uri_template).second) out.push_back(templ); - } } return out; } @@ -533,22 +527,22 @@ SkillsDirectoryProvider::get_resource_template(const std::string& uri) const ClaudeSkillsProvider::ClaudeSkillsProvider(bool reload, std::string main_file_name, SkillSupportingFiles supporting_files) - : SkillsDirectoryProvider(home_dir() / ".claude" / "skills", reload, - std::move(main_file_name), supporting_files) + : SkillsDirectoryProvider(home_dir() / ".claude" / "skills", reload, std::move(main_file_name), + supporting_files) { } CursorSkillsProvider::CursorSkillsProvider(bool reload, std::string main_file_name, SkillSupportingFiles supporting_files) - : SkillsDirectoryProvider(home_dir() / ".cursor" / "skills", reload, - std::move(main_file_name), supporting_files) + : SkillsDirectoryProvider(home_dir() / ".cursor" / "skills", reload, std::move(main_file_name), + supporting_files) { } VSCodeSkillsProvider::VSCodeSkillsProvider(bool reload, std::string main_file_name, SkillSupportingFiles supporting_files) - : SkillsDirectoryProvider(home_dir() / ".copilot" / "skills", reload, - std::move(main_file_name), supporting_files) + : SkillsDirectoryProvider(home_dir() / ".copilot" / "skills", reload, std::move(main_file_name), + supporting_files) { } @@ -563,8 +557,8 @@ CodexSkillsProvider::CodexSkillsProvider(bool reload, std::string main_file_name GeminiSkillsProvider::GeminiSkillsProvider(bool reload, std::string main_file_name, SkillSupportingFiles supporting_files) - : SkillsDirectoryProvider(home_dir() / ".gemini" / "skills", reload, - std::move(main_file_name), supporting_files) + : SkillsDirectoryProvider(home_dir() / ".gemini" / "skills", reload, std::move(main_file_name), + supporting_files) { } @@ -577,8 +571,8 @@ GooseSkillsProvider::GooseSkillsProvider(bool reload, std::string main_file_name CopilotSkillsProvider::CopilotSkillsProvider(bool reload, std::string main_file_name, SkillSupportingFiles supporting_files) - : SkillsDirectoryProvider(home_dir() / ".copilot" / "skills", reload, - std::move(main_file_name), supporting_files) + : SkillsDirectoryProvider(home_dir() / ".copilot" / "skills", reload, std::move(main_file_name), + supporting_files) { } diff --git a/src/providers/transforms/prompts_as_tools.cpp b/src/providers/transforms/prompts_as_tools.cpp index 2215f59..6a96993 100644 --- a/src/providers/transforms/prompts_as_tools.cpp +++ b/src/providers/transforms/prompts_as_tools.cpp @@ -37,8 +37,8 @@ tools::Tool PromptsAsTools::make_list_prompts_tool() const }; return tools::Tool("list_prompts", Json::object(), Json(), fn, std::nullopt, - std::optional("List available prompts and their arguments"), - std::nullopt); + std::optional("List available prompts and their arguments"), + std::nullopt); } tools::Tool PromptsAsTools::make_get_prompt_tool() const @@ -60,27 +60,23 @@ tools::Tool PromptsAsTools::make_get_prompt_tool() const if (arguments.is_object()) { for (auto it = arguments.begin(); it != arguments.end(); ++it) - { if (it.value().is_string()) vars[it.key()] = it.value().get(); else vars[it.key()] = it.value().dump(); - } } std::string rendered = prompt_opt->render(vars); return Json{{"type", "text"}, {"text", rendered}}; }; - Json schema = { - {"type", "object"}, - {"properties", Json{{"name", Json{{"type", "string"}}}, - {"arguments", Json{{"type", "object"}}}}}, - {"required", Json::array({"name"})}}; + Json schema = {{"type", "object"}, + {"properties", Json{{"name", Json{{"type", "string"}}}, + {"arguments", Json{{"type", "object"}}}}}, + {"required", Json::array({"name"})}}; return tools::Tool("get_prompt", schema, Json(), fn, std::nullopt, - std::optional("Get a rendered prompt by name"), - std::nullopt); + std::optional("Get a rendered prompt by name"), std::nullopt); } std::vector PromptsAsTools::list_tools(const ListToolsNext& call_next) const @@ -92,7 +88,7 @@ std::vector PromptsAsTools::list_tools(const ListToolsNext& call_ne } std::optional PromptsAsTools::get_tool(const std::string& name, - const GetToolNext& call_next) const + const GetToolNext& call_next) const { if (name == "list_prompts") return make_list_prompts_tool(); diff --git a/src/providers/transforms/resources_as_tools.cpp b/src/providers/transforms/resources_as_tools.cpp index 1ef99c9..16b2dab 100644 --- a/src/providers/transforms/resources_as_tools.cpp +++ b/src/providers/transforms/resources_as_tools.cpp @@ -35,9 +35,10 @@ tools::Tool ResourcesAsTools::make_list_resources_tool() const return Json{{"type", "text"}, {"text", result.dump(2)}}; }; - return tools::Tool("list_resources", Json::object(), Json(), fn, std::nullopt, - std::optional("List available resources and resource templates"), - std::nullopt); + return tools::Tool( + "list_resources", Json::object(), Json(), fn, std::nullopt, + std::optional("List available resources and resource templates"), + std::nullopt); } tools::Tool ResourcesAsTools::make_read_resource_tool() const @@ -57,7 +58,7 @@ tools::Tool ResourcesAsTools::make_read_resource_tool() const if (std::get_if>(&content.data)) return Json{{"type", "text"}, {"text", std::string("[binary data: ") + - content.mime_type.value_or("application/octet-stream") + "]"}}; + content.mime_type.value_or("application/octet-stream") + "]"}}; return Json{{"type", "text"}, {"text", ""}}; }; @@ -66,8 +67,7 @@ tools::Tool ResourcesAsTools::make_read_resource_tool() const {"required", Json::array({"uri"})}}; return tools::Tool("read_resource", schema, Json(), fn, std::nullopt, - std::optional("Read a resource by URI"), - std::nullopt); + std::optional("Read a resource by URI"), std::nullopt); } std::vector ResourcesAsTools::list_tools(const ListToolsNext& call_next) const @@ -79,7 +79,7 @@ std::vector ResourcesAsTools::list_tools(const ListToolsNext& call_ } std::optional ResourcesAsTools::get_tool(const std::string& name, - const GetToolNext& call_next) const + const GetToolNext& call_next) const { if (name == "list_resources") return make_list_resources_tool(); diff --git a/src/providers/transforms/version_filter.cpp b/src/providers/transforms/version_filter.cpp index aaf089e..b2dc941 100644 --- a/src/providers/transforms/version_filter.cpp +++ b/src/providers/transforms/version_filter.cpp @@ -87,10 +87,7 @@ VersionFilter::VersionFilter(std::optional version_gte, throw ValidationError("At least one of version_gte/version_lt must be set"); } -VersionFilter::VersionFilter(std::string version_gte) - : version_gte_(std::move(version_gte)) -{ -} +VersionFilter::VersionFilter(std::string version_gte) : version_gte_(std::move(version_gte)) {} bool VersionFilter::matches(const std::optional& version) const { diff --git a/src/server/ping_middleware.cpp b/src/server/ping_middleware.cpp index 842d8e2..9f692f2 100644 --- a/src/server/ping_middleware.cpp +++ b/src/server/ping_middleware.cpp @@ -15,8 +15,9 @@ std::pair PingMiddleware::make_hooks() const // Shared stop flag between before and after hooks auto stop_flag = std::make_shared>(false); - BeforeHook before = [stop_flag](const std::string& route, - const fastmcpp::Json& /*payload*/) -> std::optional + BeforeHook before = + [stop_flag](const std::string& route, + const fastmcpp::Json& /*payload*/) -> std::optional { if (route == "tools/call") stop_flag->store(false); @@ -24,7 +25,7 @@ std::pair PingMiddleware::make_hooks() const }; AfterHook after = [stop_flag](const std::string& route, const fastmcpp::Json& /*payload*/, - fastmcpp::Json& /*response*/) + fastmcpp::Json& /*response*/) { if (route == "tools/call") stop_flag->store(true); diff --git a/src/server/response_limiting_middleware.cpp b/src/server/response_limiting_middleware.cpp index d1877c2..9a58ce8 100644 --- a/src/server/response_limiting_middleware.cpp +++ b/src/server/response_limiting_middleware.cpp @@ -47,10 +47,8 @@ AfterHook ResponseLimitingMiddleware::make_hook() const // Concatenate all text content std::string combined; for (const auto& item : *content) - { if (item.value("type", "") == "text") combined += item.value("text", ""); - } if (combined.size() <= max_size) return; diff --git a/src/util/json_schema.cpp b/src/util/json_schema.cpp index 7bec484..4ba465d 100644 --- a/src/util/json_schema.cpp +++ b/src/util/json_schema.cpp @@ -177,7 +177,8 @@ Json dereference_refs(const Json& schema) std::vector stack; Json dereferenced = dereference_node(schema, schema, stack); - if (dereferenced.is_object() && dereferenced.contains("$defs") && !contains_ref_impl(dereferenced)) + if (dereferenced.is_object() && dereferenced.contains("$defs") && + !contains_ref_impl(dereferenced)) dereferenced.erase("$defs"); return dereferenced; diff --git a/tests/app/mcp_apps.cpp b/tests/app/mcp_apps.cpp index 8f0d059..d5317da 100644 --- a/tests/app/mcp_apps.cpp +++ b/tests/app/mcp_apps.cpp @@ -16,7 +16,7 @@ using namespace fastmcpp; { \ if (!(cond)) \ { \ - std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")\n"; \ + std::cerr << "FAIL: " << msg << " (line " << __LINE__ << ")\n"; \ return 1; \ } \ } while (0) @@ -46,7 +46,8 @@ static int test_tool_meta_ui_emitted_and_parsed() CHECK_TRUE(init.contains("result"), "initialize should return result"); auto list = handler(request(2, "tools/list")); - CHECK_TRUE(list.contains("result") && list["result"].contains("tools"), "tools/list missing tools"); + CHECK_TRUE(list.contains("result") && list["result"].contains("tools"), + "tools/list missing tools"); CHECK_TRUE(list["result"]["tools"].is_array() && list["result"]["tools"].size() == 1, "tools/list should return one tool"); @@ -57,14 +58,14 @@ static int test_tool_meta_ui_emitted_and_parsed() // Client parsing path: _meta.ui -> client::ToolInfo.app client::Client c(std::make_unique(handler)); - c.call("initialize", - Json{{"protocolVersion", "2024-11-05"}, - {"capabilities", Json::object()}, - {"clientInfo", Json{{"name", "apps-test"}, {"version", "1.0.0"}}}}); + c.call("initialize", Json{{"protocolVersion", "2024-11-05"}, + {"capabilities", Json::object()}, + {"clientInfo", Json{{"name", "apps-test"}, {"version", "1.0.0"}}}}); auto tools = c.list_tools(); CHECK_TRUE(tools.size() == 1, "client list_tools should return one tool"); CHECK_TRUE(tools[0].app.has_value(), "client tool should parse app metadata"); - CHECK_TRUE(tools[0].app->resource_uri.has_value(), "client tool app should include resource_uri"); + CHECK_TRUE(tools[0].app->resource_uri.has_value(), + "client tool app should include resource_uri"); CHECK_TRUE(*tools[0].app->resource_uri == "ui://widgets/echo.html", "client tool app resource_uri mismatch"); @@ -83,28 +84,29 @@ static int test_resource_template_ui_defaults_and_meta() res_app.prefers_border = true; res_opts.app = res_app; - app.resource("ui://widgets/home.html", "home", - [](const Json&) - { - return resources::ResourceContent{"ui://widgets/home.html", std::nullopt, - std::string{"home"}}; - }, - res_opts); + app.resource( + "ui://widgets/home.html", "home", + [](const Json&) + { + return resources::ResourceContent{"ui://widgets/home.html", std::nullopt, + std::string{"home"}}; + }, + res_opts); FastMCP::ResourceTemplateOptions templ_opts; AppConfig templ_app; templ_app.csp = Json{{"connectDomains", Json::array({"https://api.example.test"})}}; templ_opts.app = templ_app; - app.resource_template("ui://widgets/{id}.html", "widget", - [](const Json& params) - { - std::string id = params.value("id", "unknown"); - return resources::ResourceContent{"ui://widgets/" + id + ".html", - std::nullopt, - std::string{"widget"}}; - }, - Json::object(), templ_opts); + app.resource_template( + "ui://widgets/{id}.html", "widget", + [](const Json& params) + { + std::string id = params.value("id", "unknown"); + return resources::ResourceContent{"ui://widgets/" + id + ".html", std::nullopt, + std::string{"widget"}}; + }, + Json::object(), templ_opts); auto handler = mcp::make_mcp_handler(app); handler(request(10, "initialize")); @@ -162,21 +164,20 @@ static int test_template_read_inherits_ui_meta() templ_app.csp = Json{{"connectDomains", Json::array({"https://api.widgets.example.test"})}}; templ_opts.app = templ_app; - app.resource_template("ui://widgets/{id}.html", "widget", - [](const Json& params) - { - const std::string id = params.value("id", "unknown"); - return resources::ResourceContent{"ui://widgets/" + id + ".html", - std::nullopt, - std::string{"widget"}}; - }, - Json::object(), templ_opts); + app.resource_template( + "ui://widgets/{id}.html", "widget", + [](const Json& params) + { + const std::string id = params.value("id", "unknown"); + return resources::ResourceContent{"ui://widgets/" + id + ".html", std::nullopt, + std::string{"widget"}}; + }, + Json::object(), templ_opts); auto handler = mcp::make_mcp_handler(app); handler(request(30, "initialize")); - auto read = - handler(request(31, "resources/read", Json{{"uri", "ui://widgets/abc.html"}})); + auto read = handler(request(31, "resources/read", Json{{"uri", "ui://widgets/abc.html"}})); CHECK_TRUE(read.contains("result") && read["result"].contains("contents"), "resources/read should return contents"); CHECK_TRUE(read["result"]["contents"].is_array() && read["result"]["contents"].size() == 1, @@ -186,7 +187,8 @@ static int test_template_read_inherits_ui_meta() "templated resource read should include _meta.ui"); CHECK_TRUE(content["_meta"]["ui"].value("domain", "") == "https://widgets.example.test", "templated resource read should preserve app.domain"); - CHECK_TRUE(content["_meta"]["ui"].contains("csp"), "templated resource read should include app.csp"); + CHECK_TRUE(content["_meta"]["ui"].contains("csp"), + "templated resource read should include app.csp"); CHECK_TRUE(content["_meta"]["ui"]["csp"].contains("connectDomains"), "templated resource read csp should include connectDomains"); @@ -210,8 +212,7 @@ static int test_initialize_advertises_ui_extension() CHECK_TRUE(init["result"].contains("capabilities"), "initialize missing capabilities"); CHECK_TRUE(init["result"]["capabilities"].contains("extensions"), "initialize should include capabilities.extensions"); - CHECK_TRUE(init["result"]["capabilities"]["extensions"].contains( - "io.modelcontextprotocol/ui"), + CHECK_TRUE(init["result"]["capabilities"]["extensions"].contains("io.modelcontextprotocol/ui"), "initialize should advertise UI extension"); // Extension should also be advertised even if app has no explicit UI-bound resources/tools. @@ -222,9 +223,9 @@ static int test_initialize_advertises_ui_extension() "initialize (bare) should include capabilities"); CHECK_TRUE(bare_init["result"]["capabilities"].contains("extensions"), "initialize (bare) should include capabilities.extensions"); - CHECK_TRUE(bare_init["result"]["capabilities"]["extensions"].contains( - "io.modelcontextprotocol/ui"), - "initialize (bare) should advertise UI extension"); + CHECK_TRUE( + bare_init["result"]["capabilities"]["extensions"].contains("io.modelcontextprotocol/ui"), + "initialize (bare) should advertise UI extension"); return 0; } @@ -243,13 +244,14 @@ static int test_resource_app_validation_rules() invalid.resource_uri = "ui://invalid"; opts.app = invalid; - app.resource("file://bad.txt", "bad", - [](const Json&) - { - return resources::ResourceContent{"file://bad.txt", std::nullopt, - std::string{"bad"}}; - }, - opts); + app.resource( + "file://bad.txt", "bad", + [](const Json&) + { + return resources::ResourceContent{"file://bad.txt", std::nullopt, + std::string{"bad"}}; + }, + opts); } catch (const ValidationError&) { @@ -265,13 +267,10 @@ static int test_resource_app_validation_rules() invalid.visibility = std::vector{"tool_result"}; opts.app = invalid; - app.resource_template("file://{id}", "bad_templ", - [](const Json&) - { - return resources::ResourceContent{"file://x", std::nullopt, - std::string{"bad"}}; - }, - Json::object(), opts); + app.resource_template( + "file://{id}", "bad_templ", [](const Json&) + { return resources::ResourceContent{"file://x", std::nullopt, std::string{"bad"}}; }, + Json::object(), opts); } catch (const ValidationError&) { diff --git a/tests/cli/generated_cli_e2e.cpp b/tests/cli/generated_cli_e2e.cpp index 205dca0..b060879 100644 --- a/tests/cli/generated_cli_e2e.cpp +++ b/tests/cli/generated_cli_e2e.cpp @@ -1,12 +1,11 @@ #include "fastmcpp/types.hpp" #include "fastmcpp/util/json.hpp" -#include - #include #include #include #include +#include #include #include #include @@ -34,12 +33,10 @@ static std::string shell_quote(const std::string& value) std::string out = "\""; for (char c : value) - { if (c == '"') out += "\\\""; else out.push_back(c); - } out.push_back('"'); return out; } @@ -126,7 +123,8 @@ static std::string make_env_command(const std::string& var, const std::string& v int main(int argc, char** argv) { std::filesystem::path exe_dir = - std::filesystem::absolute(argc > 0 ? std::filesystem::path(argv[0]) : std::filesystem::path()) + std::filesystem::absolute(argc > 0 ? std::filesystem::path(argv[0]) + : std::filesystem::path()) .parent_path(); std::filesystem::current_path(exe_dir); @@ -156,78 +154,78 @@ int main(int argc, char** argv) const std::filesystem::path stdio_script = "generated_cli_stdio_e2e.py"; std::filesystem::remove(stdio_script, ec); - const std::string gen_stdio_cmd = - shell_quote(fastmcpp_exe.string()) + " generate-cli " + shell_quote(stdio_server_exe.string()) + - " " + shell_quote(stdio_script.string()) + " --no-skill --force --timeout 5 2>&1"; + const std::string gen_stdio_cmd = shell_quote(fastmcpp_exe.string()) + " generate-cli " + + shell_quote(stdio_server_exe.string()) + " " + + shell_quote(stdio_script.string()) + + " --no-skill --force --timeout 5 2>&1"; failures += assert_result("generate-cli stdio script", run_capture(gen_stdio_cmd), 0, "Generated CLI script"); failures += assert_result( "generated stdio list-tools", run_capture(python_cmd + " " + shell_quote(stdio_script.string()) + " list-tools 2>&1"), 0, "\"add\""); - failures += assert_result( - "generated stdio call-tool", - run_capture(python_cmd + " " + shell_quote(stdio_script.string()) + - " call-tool counter 2>&1"), - 0, "\"text\":\"1\""); + failures += assert_result("generated stdio call-tool", + run_capture(python_cmd + " " + shell_quote(stdio_script.string()) + + " call-tool counter 2>&1"), + 0, "\"text\":\"1\""); std::filesystem::remove(stdio_script, ec); const int port = 18990; const std::string host = "127.0.0.1"; std::atomic list_delay_ms{2000}; httplib::Server srv; - srv.Post("/mcp", - [&](const httplib::Request& req, httplib::Response& res) - { - if (!req.has_header("Authorization") || - req.get_header_value("Authorization") != "Bearer secret-token") - { - res.status = 401; - res.set_content("{\"error\":\"unauthorized\"}", "application/json"); - return; - } - - auto rpc = fastmcpp::util::json::parse(req.body); - const auto method = rpc.value("method", std::string()); - const auto id = rpc.value("id", Json()); - if (method == "initialize") - { - Json response = {{"jsonrpc", "2.0"}, - {"id", id}, - {"result", - {{"protocolVersion", "2024-11-05"}, - {"serverInfo", {{"name", "auth-test"}, {"version", "1.0.0"}}}, - {"capabilities", Json::object()}}}}; - res.status = 200; - res.set_header("Mcp-Session-Id", "auth-test-session"); - res.set_content(response.dump(), "application/json"); - return; - } - if (method == "tools/list") - { - std::this_thread::sleep_for(std::chrono::milliseconds(list_delay_ms.load())); - Json response = { - {"jsonrpc", "2.0"}, - {"id", id}, - {"result", - {{"tools", - Json::array({Json{{"name", "secured_tool"}, - {"inputSchema", - Json{{"type", "object"}, - {"properties", Json::object()}}}, - {"description", "secured"}}})}}}}; - res.status = 200; - res.set_header("Mcp-Session-Id", "auth-test-session"); - res.set_content(response.dump(), "application/json"); - return; - } - - Json response = {{"jsonrpc", "2.0"}, - {"id", id}, - {"error", {{"code", -32601}, {"message", "method not found"}}}}; - res.status = 200; - res.set_content(response.dump(), "application/json"); - }); + srv.Post( + "/mcp", + [&](const httplib::Request& req, httplib::Response& res) + { + if (!req.has_header("Authorization") || + req.get_header_value("Authorization") != "Bearer secret-token") + { + res.status = 401; + res.set_content("{\"error\":\"unauthorized\"}", "application/json"); + return; + } + + auto rpc = fastmcpp::util::json::parse(req.body); + const auto method = rpc.value("method", std::string()); + const auto id = rpc.value("id", Json()); + if (method == "initialize") + { + Json response = {{"jsonrpc", "2.0"}, + {"id", id}, + {"result", + {{"protocolVersion", "2024-11-05"}, + {"serverInfo", {{"name", "auth-test"}, {"version", "1.0.0"}}}, + {"capabilities", Json::object()}}}}; + res.status = 200; + res.set_header("Mcp-Session-Id", "auth-test-session"); + res.set_content(response.dump(), "application/json"); + return; + } + if (method == "tools/list") + { + std::this_thread::sleep_for(std::chrono::milliseconds(list_delay_ms.load())); + Json response = { + {"jsonrpc", "2.0"}, + {"id", id}, + {"result", + {{"tools", + Json::array({Json{{"name", "secured_tool"}, + {"inputSchema", + Json{{"type", "object"}, {"properties", Json::object()}}}, + {"description", "secured"}}})}}}}; + res.status = 200; + res.set_header("Mcp-Session-Id", "auth-test-session"); + res.set_content(response.dump(), "application/json"); + return; + } + + Json response = {{"jsonrpc", "2.0"}, + {"id", id}, + {"error", {{"code", -32601}, {"message", "method not found"}}}}; + res.status = 200; + res.set_content(response.dump(), "application/json"); + }); std::thread server_thread([&]() { srv.listen(host, port); }); srv.wait_until_ready(); @@ -236,42 +234,40 @@ int main(int argc, char** argv) const std::filesystem::path auth_script_ok = "generated_cli_auth_ok.py"; std::filesystem::remove(auth_script_ok, ec); const std::string base_url = "http://" + host + ":" + std::to_string(port) + "/mcp"; - failures += - assert_result("generate-cli auth script", - run_capture(shell_quote(fastmcpp_exe.string()) + " generate-cli " + - shell_quote(base_url) + " " + shell_quote(auth_script_ok.string()) + - " --no-skill --force --auth bearer --timeout 3 2>&1"), - 0, "Generated CLI script"); - - failures += - assert_result("generated auth requires env", - run_capture(python_cmd + " " + shell_quote(auth_script_ok.string()) + - " list-tools 2>&1"), - 2, "Missing FASTMCPP_AUTH_TOKEN"); + failures += assert_result("generate-cli auth script", + run_capture(shell_quote(fastmcpp_exe.string()) + " generate-cli " + + shell_quote(base_url) + " " + + shell_quote(auth_script_ok.string()) + + " --no-skill --force --auth bearer --timeout 3 2>&1"), + 0, "Generated CLI script"); + + failures += assert_result( + "generated auth requires env", + run_capture(python_cmd + " " + shell_quote(auth_script_ok.string()) + " list-tools 2>&1"), + 2, "Missing FASTMCPP_AUTH_TOKEN"); failures += assert_result( "generated auth list-tools success", - run_capture(make_env_command( - "FASTMCPP_AUTH_TOKEN", "secret-token", - python_cmd + " " + shell_quote(auth_script_ok.string()) + " list-tools 2>&1")), + run_capture(make_env_command("FASTMCPP_AUTH_TOKEN", "secret-token", + python_cmd + " " + shell_quote(auth_script_ok.string()) + + " list-tools 2>&1")), 0, "\"secured_tool\""); std::filesystem::remove(auth_script_ok, ec); const std::filesystem::path auth_script_timeout = "generated_cli_auth_timeout.py"; std::filesystem::remove(auth_script_timeout, ec); - failures += - assert_result("generate-cli timeout script", - run_capture(shell_quote(fastmcpp_exe.string()) + " generate-cli " + - shell_quote(base_url) + " " + - shell_quote(auth_script_timeout.string()) + - " --no-skill --force --auth bearer --timeout 1 2>&1"), - 0, "Generated CLI script"); + failures += assert_result("generate-cli timeout script", + run_capture(shell_quote(fastmcpp_exe.string()) + " generate-cli " + + shell_quote(base_url) + " " + + shell_quote(auth_script_timeout.string()) + + " --no-skill --force --auth bearer --timeout 1 2>&1"), + 0, "Generated CLI script"); failures += assert_result( "generated auth timeout enforced", - run_capture(make_env_command( - "FASTMCPP_AUTH_TOKEN", "secret-token", - python_cmd + " " + shell_quote(auth_script_timeout.string()) + " list-tools 2>&1")), + run_capture(make_env_command("FASTMCPP_AUTH_TOKEN", "secret-token", + python_cmd + " " + shell_quote(auth_script_timeout.string()) + + " list-tools 2>&1")), 124, "timed out"); std::filesystem::remove(auth_script_timeout, ec); diff --git a/tests/cli/tasks_cli.cpp b/tests/cli/tasks_cli.cpp index cc4269a..0b66b7e 100644 --- a/tests/cli/tasks_cli.cpp +++ b/tests/cli/tasks_cli.cpp @@ -138,7 +138,8 @@ int main(int argc, char** argv) { auto r = run_capture(base + " discover" + redir); - failures += assert_contains("discover requires connection", r, 2, "Missing connection options"); + failures += + assert_contains("discover requires connection", r, 2, "Missing connection options"); } { @@ -168,9 +169,10 @@ int main(int argc, char** argv) } { - auto r = run_capture(base + - " install stdio --name demo --command demo_srv --arg --mode --arg stdio --env A=B" + - redir); + auto r = run_capture( + base + + " install stdio --name demo --command demo_srv --arg --mode --arg stdio --env A=B" + + redir); failures += assert_contains("install stdio prints command", r, 0, "demo_srv"); failures += assert_contains("install stdio includes args", r, 0, "--mode"); } @@ -187,7 +189,8 @@ int main(int argc, char** argv) { auto r = run_capture(base + " install cursor --name demo --command srv" + redir); - failures += assert_contains("install cursor prints deeplink", r, 0, "cursor://anysphere.cursor-deeplink"); + failures += assert_contains("install cursor prints deeplink", r, 0, + "cursor://anysphere.cursor-deeplink"); } { @@ -209,41 +212,47 @@ int main(int argc, char** argv) } { - auto r = run_capture(base + " install claude-code --name demo --command srv --arg one" + redir); + auto r = + run_capture(base + " install claude-code --name demo --command srv --arg one" + redir); failures += assert_contains("install claude-code command", r, 0, "claude mcp add"); } { - auto r = run_capture(base + - " install mcp-json demo.server:app --name py_srv --with httpx --python 3.12" + - redir); - failures += assert_contains("install mcp-json builds uv launcher", r, 0, "\"command\": \"uv\""); + auto r = run_capture( + base + " install mcp-json demo.server:app --name py_srv --with httpx --python 3.12" + + redir); + failures += + assert_contains("install mcp-json builds uv launcher", r, 0, "\"command\": \"uv\""); failures += assert_contains("install mcp-json includes fastmcp run", r, 0, "\"fastmcp\""); - failures += assert_contains("install mcp-json includes server spec", r, 0, "\"demo.server:app\""); + failures += + assert_contains("install mcp-json includes server spec", r, 0, "\"demo.server:app\""); } { auto r = run_capture(base + - " install mcp-json demo.server:app --with httpx --with-editable ./pkg --project . --with-requirements req.txt" + + " install mcp-json demo.server:app --with httpx --with-editable ./pkg " + "--project . --with-requirements req.txt" + redir); failures += assert_contains("install mcp-json includes --with", r, 0, "\"--with\""); - failures += - assert_contains("install mcp-json includes --with-editable", r, 0, "\"--with-editable\""); - failures += - assert_contains("install mcp-json includes --with-requirements", r, 0, - "\"--with-requirements\""); + failures += assert_contains("install mcp-json includes --with-editable", r, 0, + "\"--with-editable\""); + failures += assert_contains("install mcp-json includes --with-requirements", r, 0, + "\"--with-requirements\""); failures += assert_contains("install mcp-json includes --project", r, 0, "\"--project\""); } { - auto r = run_capture(base + " install gemini-cli --name demo --command srv --arg one" + redir); + auto r = + run_capture(base + " install gemini-cli --name demo --command srv --arg one" + redir); failures += assert_contains("install gemini-cli command", r, 0, "gemini mcp add"); } { - auto r = run_capture(base + " install claude-desktop demo.server:app --name desktop_srv" + redir); + auto r = run_capture(base + " install claude-desktop demo.server:app --name desktop_srv" + + redir); failures += assert_contains("install claude-desktop config", r, 0, "\"mcpServers\""); - failures += assert_contains("install claude-desktop includes server", r, 0, "\"desktop_srv\""); + failures += + assert_contains("install claude-desktop includes server", r, 0, "\"desktop_srv\""); } { @@ -253,7 +262,8 @@ int main(int argc, char** argv) { auto r = run_capture(base + " install nope" + redir); - failures += assert_contains("install rejects unknown target", r, 2, "Unknown install target"); + failures += + assert_contains("install rejects unknown target", r, 2, "Unknown install target"); } { @@ -315,10 +325,10 @@ int main(int argc, char** argv) std::filesystem::remove(out_file, ec); std::filesystem::remove(skill_file, ec); - auto r = run_capture(base + " generate-cli demo_server.py " + out_file.string() + " --force" + - redir); - failures += assert_contains("generate-cli accepts positional output", r, 0, - "Generated CLI script"); + auto r = run_capture(base + " generate-cli demo_server.py " + out_file.string() + + " --force" + redir); + failures += + assert_contains("generate-cli accepts positional output", r, 0, "Generated CLI script"); std::filesystem::remove(out_file, ec); std::filesystem::remove(skill_file, ec); } @@ -344,27 +354,30 @@ int main(int argc, char** argv) } { - auto r = run_capture(base + " generate-cli demo_server.py --auth invalid --no-skill --force" + - redir); - failures += assert_contains("generate-cli rejects invalid auth", r, 2, - "Unsupported --auth mode"); + auto r = run_capture( + base + " generate-cli demo_server.py --auth invalid --no-skill --force" + redir); + failures += + assert_contains("generate-cli rejects invalid auth", r, 2, "Unsupported --auth mode"); } { auto out_file = std::filesystem::path("fastmcpp_cli_generated_auth.py"); std::error_code ec; std::filesystem::remove(out_file, ec); - auto r = run_capture(base + - " generate-cli demo_server.py --auth bearer --timeout 7 --no-skill --force --output " + - out_file.string() + redir); - failures += assert_contains("generate-cli accepts auth+timeout", r, 0, "Generated CLI script"); + auto r = run_capture( + base + + " generate-cli demo_server.py --auth bearer --timeout 7 --no-skill --force --output " + + out_file.string() + redir); + failures += + assert_contains("generate-cli accepts auth+timeout", r, 0, "Generated CLI script"); if (std::filesystem::exists(out_file)) { std::ifstream in(out_file); std::stringstream content; content << in.rdbuf(); const auto script = content.str(); - if (!contains(script, "AUTH_MODE = 'bearer'") || !contains(script, "DEFAULT_TIMEOUT = 7")) + if (!contains(script, "AUTH_MODE = 'bearer'") || + !contains(script, "DEFAULT_TIMEOUT = 7")) { std::cerr << "[FAIL] generate-cli auth/timeout not rendered in script\n"; ++failures; diff --git a/tests/mcp/test_error_codes.cpp b/tests/mcp/test_error_codes.cpp index 144dbd3..158eb0c 100644 --- a/tests/mcp/test_error_codes.cpp +++ b/tests/mcp/test_error_codes.cpp @@ -13,12 +13,11 @@ int main() { // Build a FastMCP app with one tool but no resources or prompts FastMCP app("test_error_codes", "1.0.0"); - app.tool( - "echo", - Json{{"type", "object"}, - {"properties", {{"msg", {{"type", "string"}}}}}, - {"required", Json::array({"msg"})}}, - [](const Json& args) { return Json{{"echo", args.at("msg")}}; }); + app.tool("echo", + Json{{"type", "object"}, + {"properties", {{"msg", {{"type", "string"}}}}}, + {"required", Json::array({"msg"})}}, + [](const Json& args) { return Json{{"echo", args.at("msg")}}; }); auto handler = mcp::make_mcp_handler(app); diff --git a/tests/providers/openapi_provider.cpp b/tests/providers/openapi_provider.cpp index 9f96382..c6e047f 100644 --- a/tests/providers/openapi_provider.cpp +++ b/tests/providers/openapi_provider.cpp @@ -1,10 +1,10 @@ -#include "fastmcpp/app.hpp" #include "fastmcpp/providers/openapi_provider.hpp" -#include +#include "fastmcpp/app.hpp" #include #include +#include #include using namespace fastmcpp; @@ -13,20 +13,21 @@ int main() { httplib::Server server; - server.Get(R"(/api/users/([^/]+))", [](const httplib::Request& req, httplib::Response& res) - { - Json body = { - {"id", req.matches[1].str()}, - {"verbose", req.has_param("verbose") ? req.get_param_value("verbose") : "false"}, - }; - res.set_content(body.dump(), "application/json"); - }); + server.Get( + R"(/api/users/([^/]+))", + [](const httplib::Request& req, httplib::Response& res) + { + Json body = { + {"id", req.matches[1].str()}, + {"verbose", req.has_param("verbose") ? req.get_param_value("verbose") : "false"}, + }; + res.set_content(body.dump(), "application/json"); + }); server.Post("/api/echo", [](const httplib::Request& req, httplib::Response& res) { res.set_content(req.body, "application/json"); }); - std::thread server_thread([&]() - { server.listen("127.0.0.1", 18888); }); + std::thread server_thread([&]() { server.listen("127.0.0.1", 18888); }); std::this_thread::sleep_for(std::chrono::milliseconds(150)); Json spec = Json::object(); @@ -44,26 +45,25 @@ int main() spec["paths"]["/api/users/{id}"]["get"] = Json{ {"operationId", "getUser"}, - {"parameters", - Json::array({ - Json{{"name", "id"}, - {"in", "path"}, - {"required", true}, - {"schema", Json{{"type", "string"}}}}, - Json{{"name", "verbose"}, - {"in", "query"}, - {"required", true}, - {"description", "operation-level verbose flag"}, - {"schema", Json{{"type", "boolean"}}}}, - })}, + {"parameters", Json::array({ + Json{{"name", "id"}, + {"in", "path"}, + {"required", true}, + {"schema", Json{{"type", "string"}}}}, + Json{{"name", "verbose"}, + {"in", "query"}, + {"required", true}, + {"description", "operation-level verbose flag"}, + {"schema", Json{{"type", "boolean"}}}}, + })}, {"responses", Json{{"200", Json{{"description", "ok"}, {"content", Json{{"application/json", - Json{{"schema", - Json{{"type", "object"}, - {"properties", Json{{"id", Json{{"type", "string"}}}}}}}}}}}}}}}, + Json{{"schema", Json{{"type", "object"}, + {"properties", + Json{{"id", Json{{"type", "string"}}}}}}}}}}}}}}}, }; spec["paths"]["/api/echo"]["post"] = Json{ @@ -74,17 +74,15 @@ int main() Json{{"application/json", Json{{"schema", Json{{"type", "object"}, - {"properties", - Json{{"message", Json{{"type", "string"}}}}}}}}}}}}}, + {"properties", Json{{"message", Json{{"type", "string"}}}}}}}}}}}}}, {"responses", - Json{{"200", - Json{{"description", "ok"}, - {"content", - Json{{"application/json", - Json{{"schema", - Json{{"type", "object"}, - {"properties", - Json{{"message", Json{{"type", "string"}}}}}}}}}}}}}}}, + Json{{"200", Json{{"description", "ok"}, + {"content", + Json{{"application/json", + Json{{"schema", Json{{"type", "object"}, + {"properties", + Json{{"message", + Json{{"type", "string"}}}}}}}}}}}}}}}, }; auto provider = std::make_shared(spec); diff --git a/tests/providers/skills_provider.cpp b/tests/providers/skills_provider.cpp index 171b133..6dba365 100644 --- a/tests/providers/skills_provider.cpp +++ b/tests/providers/skills_provider.cpp @@ -1,6 +1,7 @@ -#include "fastmcpp/app.hpp" #include "fastmcpp/providers/skills_provider.hpp" +#include "fastmcpp/app.hpp" + #include #include #include @@ -39,16 +40,15 @@ int main() { const auto root = make_temp_dir("single"); const auto skill = root / "pdf-processing"; - write_text(skill / "SKILL.md", - "---\n" - "description: \"Frontmatter PDF skill\"\n" - "version: \"1.0.0\"\n" - "---\n\n" - "# PDF Processing\nRead PDF files."); + write_text(skill / "SKILL.md", "---\n" + "description: \"Frontmatter PDF skill\"\n" + "version: \"1.0.0\"\n" + "---\n\n" + "# PDF Processing\nRead PDF files."); write_text(skill / "notes" / "guide.txt", "guide"); - auto provider = - std::make_shared(skill, "SKILL.md", providers::SkillSupportingFiles::Template); + auto provider = std::make_shared( + skill, "SKILL.md", providers::SkillSupportingFiles::Template); FastMCP app("skills", "1.0.0"); app.add_provider(provider); diff --git a/tests/providers/version_filter.cpp b/tests/providers/version_filter.cpp index 4e64116..9b46e74 100644 --- a/tests/providers/version_filter.cpp +++ b/tests/providers/version_filter.cpp @@ -1,7 +1,8 @@ +#include "fastmcpp/providers/transforms/version_filter.hpp" + #include "fastmcpp/app.hpp" #include "fastmcpp/exceptions.hpp" #include "fastmcpp/providers/local_provider.hpp" -#include "fastmcpp/providers/transforms/version_filter.hpp" #include #include @@ -14,7 +15,8 @@ namespace { tools::Tool make_tool(const std::string& name, const std::string& version, int value) { - tools::Tool tool(name, Json::object(), Json::object(), [value](const Json&) { return Json(value); }); + tools::Tool tool(name, Json::object(), Json::object(), + [value](const Json&) { return Json(value); }); if (!version.empty()) tool.set_version(version); return tool; @@ -80,8 +82,8 @@ int main() provider->add_template(make_template("res://v2/{id}", "2.0")); provider->add_prompt(make_prompt("legacy_prompt", "1.0")); provider->add_prompt(make_prompt("v2_prompt", "2.0")); - provider->add_transform( - std::make_shared(std::string("2.0"), std::string("3.0"))); + provider->add_transform(std::make_shared( + std::string("2.0"), std::string("3.0"))); FastMCP app("version_filter", "1.0.0"); app.add_provider(provider); diff --git a/tests/proxy/basic.cpp b/tests/proxy/basic.cpp index 7a68ecf..e2f79e0 100644 --- a/tests/proxy/basic.cpp +++ b/tests/proxy/basic.cpp @@ -496,8 +496,8 @@ void test_proxy_resource_annotations() {"clientInfo", Json{{"name", "test"}, {"version", "1.0"}}}}}}); // Test resources/list serialization - auto resources_response = handler( - Json{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "resources/list"}, {"params", Json::object()}}); + auto resources_response = handler(Json{ + {"jsonrpc", "2.0"}, {"id", 2}, {"method", "resources/list"}, {"params", Json::object()}}); assert(resources_response.contains("result")); assert(resources_response["result"].contains("resources")); diff --git a/tests/schema/dereference_toggle.cpp b/tests/schema/dereference_toggle.cpp index e3f6292..d16cfab 100644 --- a/tests/schema/dereference_toggle.cpp +++ b/tests/schema/dereference_toggle.cpp @@ -12,12 +12,11 @@ Json make_tool_input_schema() return Json{ {"type", "object"}, {"$defs", Json{{"City", Json{{"type", "string"}, {"enum", Json::array({"sf", "nyc"})}}}}}, - {"properties", - Json{{"city", - Json{ - {"$ref", "#/$defs/City"}, - {"description", "City name"}, - }}}}, + {"properties", Json{{"city", + Json{ + {"$ref", "#/$defs/City"}, + {"description", "City name"}, + }}}}, {"required", Json::array({"city"})}, }; } @@ -66,19 +65,20 @@ void register_components(FastMCP& app) { FastMCP::ToolOptions opts; opts.output_schema = make_tool_output_schema(); - app.tool("weather", make_tool_input_schema(), - [](const Json&) - { return Json{{"temperature", 70}}; }, opts); - - app.resource_template("skill://demo/{path*}", "skill_files", - [](const Json&) - { - resources::ResourceContent content; - content.uri = "skill://demo/readme"; - content.data = std::string("ok"); - return content; - }, - make_template_parameters_schema()); + app.tool( + "weather", make_tool_input_schema(), [](const Json&) { return Json{{"temperature", 70}}; }, + opts); + + app.resource_template( + "skill://demo/{path*}", "skill_files", + [](const Json&) + { + resources::ResourceContent content; + content.uri = "skill://demo/readme"; + content.data = std::string("ok"); + return content; + }, + make_template_parameters_schema()); } void test_dereference_enabled_by_default() diff --git a/tests/server/streamable_http_integration.cpp b/tests/server/streamable_http_integration.cpp index f730199..b1f4c6f 100644 --- a/tests/server/streamable_http_integration.cpp +++ b/tests/server/streamable_http_integration.cpp @@ -391,7 +391,8 @@ void test_invalid_tool_maps_to_invalid_params_error_code() tools::ToolManager tool_mgr; std::unordered_map descriptions; - auto handler = mcp::make_mcp_handler("invalid_tool_error_code", "1.0.0", tool_mgr, descriptions); + auto handler = + mcp::make_mcp_handler("invalid_tool_error_code", "1.0.0", tool_mgr, descriptions); server::StreamableHttpServerWrapper server(handler, host, port, "/mcp"); bool started = server.start(); diff --git a/tests/server/test_response_limiting.cpp b/tests/server/test_response_limiting.cpp index c428cb2..f432a58 100644 --- a/tests/server/test_response_limiting.cpp +++ b/tests/server/test_response_limiting.cpp @@ -16,8 +16,7 @@ void test_response_under_limit_unchanged() ResponseLimitingMiddleware mw(100); auto hook = mw.make_hook(); - Json response = { - {"content", Json::array({{{"type", "text"}, {"text", "short response"}}})}}; + Json response = {{"content", Json::array({{{"type", "text"}, {"text", "short response"}}})}}; hook("tools/call", Json::object(), response); assert(response["content"][0]["text"] == "short response"); @@ -134,8 +133,8 @@ void test_server_after_hook_integration() server.route("tools/call", [](const Json&) { - return Json{ - {"content", Json::array({{{"type", "text"}, {"text", std::string(80, 'F')}}})}}; + return Json{{"content", Json::array({{{"type", "text"}, + {"text", std::string(80, 'F')}}})}}; }); Json response = server.handle("tools/call", Json{{"name", "long_tool"}}); diff --git a/tests/server/test_server_session.cpp b/tests/server/test_server_session.cpp index ae4febe..92b9109 100644 --- a/tests/server/test_server_session.cpp +++ b/tests/server/test_server_session.cpp @@ -69,9 +69,8 @@ void test_extension_capabilities() ServerSession session("sess_ext", nullptr); Json caps = { {"tools", Json::object()}, - {"extensions", - Json{{"io.modelcontextprotocol/ui", Json::object()}, - {"example.extension", Json{{"enabled", true}}}}}, + {"extensions", Json{{"io.modelcontextprotocol/ui", Json::object()}, + {"example.extension", Json{{"enabled", true}}}}}, }; session.set_capabilities(caps); diff --git a/tests/tools/test_tool_sequential.cpp b/tests/tools/test_tool_sequential.cpp index af446d3..3bf9cc8 100644 --- a/tests/tools/test_tool_sequential.cpp +++ b/tests/tools/test_tool_sequential.cpp @@ -12,10 +12,8 @@ using namespace fastmcpp; void test_tool_sequential_flag() { - tools::Tool tool("test", - Json{{"type", "object"}, {"properties", Json::object()}}, - Json::object(), - [](const Json&) { return Json{{"ok", true}}; }); + tools::Tool tool("test", Json{{"type", "object"}, {"properties", Json::object()}}, + Json::object(), [](const Json&) { return Json{{"ok", true}}; }); // Default: not sequential assert(!tool.sequential()); @@ -34,11 +32,12 @@ void test_fastmcp_tool_registration_sequential() FastMCP::ToolOptions opts; opts.sequential = true; - app.tool("seq_tool", - Json{{"type", "object"}, - {"properties", {{"x", {{"type", "integer"}}}}}, - {"required", Json::array({"x"})}}, - [](const Json& args) { return args.at("x"); }, opts); + app.tool( + "seq_tool", + Json{{"type", "object"}, + {"properties", {{"x", {{"type", "integer"}}}}}, + {"required", Json::array({"x"})}}, + [](const Json& args) { return args.at("x"); }, opts); // Verify the tool info includes execution.concurrency auto tools_info = app.list_all_tools_info(); diff --git a/tests/tools/test_tool_transform_enabled.cpp b/tests/tools/test_tool_transform_enabled.cpp index a1c6274..c377ad4 100644 --- a/tests/tools/test_tool_transform_enabled.cpp +++ b/tests/tools/test_tool_transform_enabled.cpp @@ -64,8 +64,7 @@ void test_hidden_tool_filtered_by_provider() hide_config.enabled = false; std::unordered_map transforms; transforms["tool_b"] = hide_config; - provider->add_transform( - std::make_shared(transforms)); + provider->add_transform(std::make_shared(transforms)); auto tools = provider->list_tools_transformed(); assert(tools.size() == 1); From 8bf2ffffc76e911347ae9f109254beae9a759020 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 16 Feb 2026 18:29:40 -0800 Subject: [PATCH 4/6] feat: replace tiny-process-library with SDK process code (CreateProcessW) Replace the FetchContent dependency on tiny-process-library with internal cross-platform process code adapted from copilot-sdk-cpp. This eliminates an external dependency that was transitively inherited by downstream projects (libagents -> fastmcpp_core). Win32 implementation upgraded from CreateProcessA to CreateProcessW: - UTF-8/UTF-16 conversion via utf8_to_wide() helper - STARTUPINFOEXW with explicit handle inheritance (PROC_THREAD_ATTRIBUTE_HANDLE_LIST) - Job Object for automatic child cleanup - CREATE_NO_WINDOW to prevent console popups - Stderr redirected to NUL when not captured StdioTransport rewritten from callback-based (deque + condition_variable) to synchronous pipe model (direct read_line on stdout pipe), with a background stderr reader thread in keep-alive mode to prevent pipe buffer deadlock. Process liveness is checked during timeout polling (200ms intervals) for fast failure detection when servers crash. Also fixes two CI issues from PR #31: - openapi_provider.hpp: brace-init default param -> explicit Options{} - transports.hpp: add missing override on session_id/has_session New tests: stdio_lifecycle, stdio_stderr, stdio_timeout. All 89 tests pass. --- CMakeLists.txt | 40 +- include/fastmcpp/client/transports.hpp | 8 +- .../fastmcpp/providers/openapi_provider.hpp | 11 +- src/client/transports.cpp | 346 +++++--- src/internal/process.cpp | 9 + src/internal/process.hpp | 160 ++++ src/internal/process_posix.cpp | 617 ++++++++++++++ src/internal/process_win32.cpp | 788 ++++++++++++++++++ src/providers/openapi_provider.cpp | 11 + tests/transports/stdio_failure.cpp | 4 - tests/transports/stdio_lifecycle.cpp | 105 +++ tests/transports/stdio_stderr.cpp | 98 +++ tests/transports/stdio_timeout.cpp | 55 ++ 13 files changed, 2088 insertions(+), 164 deletions(-) create mode 100644 src/internal/process.cpp create mode 100644 src/internal/process.hpp create mode 100644 src/internal/process_posix.cpp create mode 100644 src/internal/process_win32.cpp create mode 100644 tests/transports/stdio_lifecycle.cpp create mode 100644 tests/transports/stdio_stderr.cpp create mode 100644 tests/transports/stdio_timeout.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 871486f..524c4a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,24 +169,8 @@ if(FASTMCPP_ENABLE_POST_STREAMING OR FASTMCPP_ENABLE_SAMPLING_HTTP_HANDLERS) endif() endif() -# TinyProcessLib for cross-platform subprocess (header-only) -FetchContent_Declare( - tiny_process_lib - GIT_REPOSITORY https://github.com/eidheim/tiny-process-library.git - GIT_TAG master -) -FetchContent_GetProperties(tiny_process_lib) -if(NOT tiny_process_lib_POPULATED) - FetchContent_Populate(tiny_process_lib) -endif() -target_include_directories(fastmcpp_core PUBLIC ${tiny_process_lib_SOURCE_DIR}) -target_compile_definitions(fastmcpp_core PUBLIC TINY_PROCESS_LIB_AVAILABLE) -target_sources(fastmcpp_core PRIVATE ${tiny_process_lib_SOURCE_DIR}/process.cpp) -if(UNIX) - target_sources(fastmcpp_core PRIVATE ${tiny_process_lib_SOURCE_DIR}/process_unix.cpp) -elseif(WIN32) - target_sources(fastmcpp_core PRIVATE ${tiny_process_lib_SOURCE_DIR}/process_win.cpp) -endif() +# Cross-platform subprocess management (replaces tiny-process-library) +target_sources(fastmcpp_core PRIVATE src/internal/process.cpp) find_package(Threads REQUIRED) target_link_libraries(fastmcpp_core PRIVATE Threads::Threads) @@ -466,6 +450,26 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_stdio_failure PRIVATE fastmcpp_core) add_test(NAME fastmcpp_stdio_failure COMMAND fastmcpp_stdio_failure) + add_executable(fastmcpp_stdio_lifecycle tests/transports/stdio_lifecycle.cpp) + target_link_libraries(fastmcpp_stdio_lifecycle PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_stdio_lifecycle COMMAND fastmcpp_stdio_lifecycle) + set_tests_properties(fastmcpp_stdio_lifecycle PROPERTIES + WORKING_DIRECTORY "$" + ) + + add_executable(fastmcpp_stdio_stderr tests/transports/stdio_stderr.cpp) + target_link_libraries(fastmcpp_stdio_stderr PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_stdio_stderr COMMAND fastmcpp_stdio_stderr) + set_tests_properties(fastmcpp_stdio_stderr PROPERTIES + WORKING_DIRECTORY "$" + ) + + add_executable(fastmcpp_stdio_timeout tests/transports/stdio_timeout.cpp) + target_link_libraries(fastmcpp_stdio_timeout PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_stdio_timeout COMMAND fastmcpp_stdio_timeout) + # Timeout test can take up to ~35 seconds + set_tests_properties(fastmcpp_stdio_timeout PROPERTIES TIMEOUT 60) + # App mounting tests add_executable(fastmcpp_app_mounting tests/app/mounting.cpp) target_link_libraries(fastmcpp_app_mounting PRIVATE fastmcpp_core) diff --git a/include/fastmcpp/client/transports.hpp b/include/fastmcpp/client/transports.hpp index d842dd4..91ef0f2 100644 --- a/include/fastmcpp/client/transports.hpp +++ b/include/fastmcpp/client/transports.hpp @@ -132,10 +132,10 @@ class SseClientTransport : public ITransport, bool is_connected() const; /// Get the current MCP session ID (from the SSE "endpoint" event). - std::string session_id() const; + std::string session_id() const override; /// Check if a session ID has been set. - bool has_session() const; + bool has_session() const override; void set_server_request_handler(ServerRequestHandler handler) override; @@ -195,10 +195,10 @@ class StreamableHttpTransport : public ITransport, fastmcpp::Json request(const std::string& route, const fastmcpp::Json& payload) override; /// Get the session ID (set after successful initialize) - std::string session_id() const; + std::string session_id() const override; /// Check if a session ID has been set - bool has_session() const; + bool has_session() const override; /// Set callback for handling server-initiated notifications during streaming responses void set_notification_callback(std::function callback); diff --git a/include/fastmcpp/providers/openapi_provider.hpp b/include/fastmcpp/providers/openapi_provider.hpp index 4b7514d..44c4ab1 100644 --- a/include/fastmcpp/providers/openapi_provider.hpp +++ b/include/fastmcpp/providers/openapi_provider.hpp @@ -15,16 +15,17 @@ class OpenAPIProvider : public Provider public: struct Options { - bool validate_output{true}; + bool validate_output = true; std::map mcp_names; // operationId -> component name }; - explicit OpenAPIProvider(Json openapi_spec, std::optional base_url = std::nullopt, - Options options = {}); + explicit OpenAPIProvider(Json openapi_spec, std::optional base_url = std::nullopt); + OpenAPIProvider(Json openapi_spec, std::optional base_url, Options options); static OpenAPIProvider from_file(const std::string& file_path, - std::optional base_url = std::nullopt, - Options options = {}); + std::optional base_url = std::nullopt); + static OpenAPIProvider from_file(const std::string& file_path, + std::optional base_url, Options options); std::vector list_tools() const override; std::optional get_tool(const std::string& name) const override; diff --git a/src/client/transports.cpp b/src/client/transports.cpp index 3b302fd..fe445d4 100644 --- a/src/client/transports.cpp +++ b/src/client/transports.cpp @@ -1,11 +1,11 @@ #include "fastmcpp/client/transports.hpp" +#include "../internal/process.hpp" #include "fastmcpp/exceptions.hpp" #include "fastmcpp/util/json.hpp" +#include #include -#include -#include #include #include #include @@ -14,27 +14,22 @@ #ifdef FASTMCPP_POST_STREAMING #include #endif -#ifdef TINY_PROCESS_LIB_AVAILABLE -#include -#endif namespace fastmcpp::client { struct StdioTransport::State { -#ifdef TINY_PROCESS_LIB_AVAILABLE - std::unique_ptr process; - std::ofstream log_file_stream; - std::ostream* stderr_target{nullptr}; - + fastmcpp::process::Process process; std::mutex request_mutex; - std::mutex mutex; - std::condition_variable cv; - std::string stdout_partial; - std::deque stdout_lines; + // Stderr background reader (keep-alive mode) + std::thread stderr_thread; + std::atomic stderr_running{false}; + std::mutex stderr_mutex; std::string stderr_data; -#endif + // Logging + std::ofstream log_file_stream; + std::ostream* stderr_target{nullptr}; }; namespace @@ -466,18 +461,11 @@ StdioTransport::StdioTransport(std::string command, std::vector arg fastmcpp::Json StdioTransport::request(const std::string& route, const fastmcpp::Json& payload) { - // Use TinyProcessLibrary (fetched via CMake) for cross-platform subprocess handling - // Build command line - std::ostringstream cmd; - cmd << command_; - for (const auto& a : args_) - cmd << " " << a; - -#ifdef TINY_PROCESS_LIB_AVAILABLE - using namespace TinyProcessLib; + namespace proc = fastmcpp::process; if (keep_alive_) { + // --- Keep-alive mode: spawn once, reuse across calls --- if (!state_) { state_ = std::make_unique(); @@ -493,47 +481,62 @@ fastmcpp::Json StdioTransport::request(const std::string& route, const fastmcpp: state_->stderr_target = log_stream_; } - auto stdout_callback = [st_ptr = state_.get()](const char* bytes, size_t n) + try { - std::lock_guard lock(st_ptr->mutex); - st_ptr->stdout_partial.append(bytes, n); - - for (;;) - { - auto pos = st_ptr->stdout_partial.find('\n'); - if (pos == std::string::npos) - break; - - std::string line = st_ptr->stdout_partial.substr(0, pos); - if (!line.empty() && line.back() == '\r') - line.pop_back(); - st_ptr->stdout_lines.push_back(std::move(line)); - st_ptr->stdout_partial.erase(0, pos + 1); - } - - st_ptr->cv.notify_all(); - }; - - auto stderr_callback = [st_ptr = state_.get()](const char* bytes, size_t n) + state_->process.spawn(command_, args_, + proc::ProcessOptions{/*working_directory=*/{}, + /*environment=*/{}, + /*inherit_environment=*/true, + /*redirect_stdin=*/true, + /*redirect_stdout=*/true, + /*redirect_stderr=*/true, + /*create_no_window=*/true}); + } + catch (const proc::ProcessError& e) { - std::lock_guard lock(st_ptr->mutex); - if (st_ptr->stderr_target != nullptr) - { - st_ptr->stderr_target->write(bytes, n); - st_ptr->stderr_target->flush(); - } - st_ptr->stderr_data.append(bytes, n); - }; + state_.reset(); + throw fastmcpp::TransportError(std::string("StdioTransport: spawn failed: ") + + e.what()); + } - state_->process = std::make_unique(cmd.str(), "", stdout_callback, - stderr_callback, /*open_stdin*/ true); + // Background stderr reader to prevent pipe buffer deadlock + state_->stderr_running.store(true, std::memory_order_release); + state_->stderr_thread = std::thread( + [st = state_.get()]() + { + char buf[1024]; + while (st->stderr_running.load(std::memory_order_acquire)) + { + try + { + if (!st->process.stderr_pipe().is_open()) + break; + if (!st->process.stderr_pipe().has_data(50)) + continue; + size_t n = st->process.stderr_pipe().read(buf, sizeof(buf)); + if (n == 0) + break; + std::lock_guard lock(st->stderr_mutex); + if (st->stderr_target) + { + st->stderr_target->write(buf, static_cast(n)); + st->stderr_target->flush(); + } + st->stderr_data.append(buf, n); + } + catch (...) + { + break; + } + } + }); } auto* st = state_.get(); std::lock_guard request_lock(st->request_mutex); const int64_t id = next_id_++; - fastmcpp::Json request = { + fastmcpp::Json rpc_request = { {"jsonrpc", "2.0"}, {"id", id}, {"method", route}, @@ -541,73 +544,146 @@ fastmcpp::Json StdioTransport::request(const std::string& route, const fastmcpp: }; { - std::lock_guard lock(st->mutex); + std::lock_guard lock(st->stderr_mutex); st->stderr_data.clear(); } - if (!st->process->write(request.dump() + "\n")) - throw fastmcpp::TransportError("StdioTransport: failed to write request"); + try + { + st->process.stdin_pipe().write(rpc_request.dump() + "\n"); + } + catch (const proc::ProcessError& e) + { + throw fastmcpp::TransportError(std::string("StdioTransport: failed to write: ") + + e.what()); + } - // Wait for a response matching this ID. - // Note: stdio servers may emit notifications or logs; ignore non-matching lines. + // Read lines from stdout until we get a JSON response matching our ID for (;;) { - int exit_status = 0; - if (st->process->try_get_exit_status(exit_status)) + // Check if process exited + auto exit_code = st->process.try_wait(); + if (exit_code.has_value()) { - std::lock_guard lock(st->mutex); + std::lock_guard lock(st->stderr_mutex); throw fastmcpp::TransportError( - "StdioTransport process exited with code: " + std::to_string(exit_status) + - (st->stderr_data.empty() ? std::string("") : ("; stderr: ") + st->stderr_data)); + "StdioTransport process exited with code: " + std::to_string(*exit_code) + + (st->stderr_data.empty() ? std::string() : "; stderr: " + st->stderr_data)); } - std::unique_lock lock(st->mutex); - if (!st->cv.wait_for(lock, std::chrono::seconds(30), - [&]() { return !st->stdout_lines.empty(); })) + // Wait for data with timeout, checking process liveness periodically + bool have_data = false; + constexpr int total_timeout_ms = 30000; + constexpr int poll_ms = 200; + for (int elapsed = 0; elapsed < total_timeout_ms; elapsed += poll_ms) { - throw fastmcpp::TransportError("StdioTransport: timed out waiting for response"); - } - - while (!st->stdout_lines.empty()) - { - auto line = std::move(st->stdout_lines.front()); - st->stdout_lines.pop_front(); - lock.unlock(); - - if (line.empty()) + if (st->process.stdout_pipe().has_data(poll_ms)) { - lock.lock(); - continue; + have_data = true; + break; } - - try + // Re-check process liveness during the wait + auto code = st->process.try_wait(); + if (code.has_value()) { - auto parsed = fastmcpp::util::json::parse(line); - if (parsed.contains("id") && parsed["id"].is_number_integer() && - parsed["id"].get() == id) + // Drain any remaining data from stdout before throwing + if (st->process.stdout_pipe().has_data(0)) { - return parsed; + have_data = true; + break; } + std::lock_guard lock(st->stderr_mutex); + throw fastmcpp::TransportError( + "StdioTransport process exited with code: " + std::to_string(*code) + + (st->stderr_data.empty() ? std::string() : "; stderr: " + st->stderr_data)); } - catch (...) + } + if (!have_data) + throw fastmcpp::TransportError("StdioTransport: timed out waiting for response"); + + std::string line = st->process.stdout_pipe().read_line(); + // Strip trailing \r\n + while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) + line.pop_back(); + + if (line.empty()) + continue; + + try + { + auto parsed = fastmcpp::util::json::parse(line); + if (parsed.contains("id") && parsed["id"].is_number_integer() && + parsed["id"].get() == id) { - // Ignore non-JSON stdout lines (e.g., server logs). + return parsed; } - - lock.lock(); + } + catch (...) + { + // Ignore non-JSON stdout lines (e.g., server logs) } } } + + // --- One-shot mode: spawn per call --- + proc::Process process; + try + { + process.spawn(command_, args_, + proc::ProcessOptions{/*working_directory=*/{}, + /*environment=*/{}, + /*inherit_environment=*/true, + /*redirect_stdin=*/true, + /*redirect_stdout=*/true, + /*redirect_stderr=*/true, + /*create_no_window=*/true}); + } + catch (const proc::ProcessError& e) + { + throw fastmcpp::TransportError(std::string("StdioTransport: spawn failed: ") + e.what()); + } + + // Write request then close stdin + fastmcpp::Json rpc_request = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", route}, + {"params", payload}, + }; + process.stdin_pipe().write(rpc_request.dump() + "\n"); + process.stdin_pipe().close(); + + // Read all stdout synchronously std::string stdout_data; + { + char buf[4096]; + for (;;) + { + size_t n = process.stdout_pipe().read(buf, sizeof(buf)); + if (n == 0) + break; + stdout_data.append(buf, n); + } + } + + // Read all stderr synchronously std::string stderr_data; + { + char buf[4096]; + for (;;) + { + size_t n = process.stderr_pipe().read(buf, sizeof(buf)); + if (n == 0) + break; + stderr_data.append(buf, n); + } + } - // Open log file if path was provided (RAII - closes automatically) - std::ofstream log_file_stream; + // Log stderr if configured std::ostream* stderr_target = nullptr; - + std::ofstream log_file_stream; if (log_file_.has_value()) { - // Open file in append mode log_file_stream.open(log_file_.value(), std::ios::app); if (log_file_stream.is_open()) stderr_target = &log_file_stream; @@ -616,49 +692,29 @@ fastmcpp::Json StdioTransport::request(const std::string& route, const fastmcpp: { stderr_target = log_stream_; } - - // Stderr callback: write to log file/stream if configured, otherwise capture - auto stderr_callback = [&](const char* bytes, size_t n) + if (stderr_target && !stderr_data.empty()) { - if (stderr_target != nullptr) - { - stderr_target->write(bytes, n); - stderr_target->flush(); - } - // Always capture for error messages (in case of process failure) - stderr_data.append(bytes, n); - }; - - Process process( - cmd.str(), "", [&](const char* bytes, size_t n) { stdout_data.append(bytes, n); }, - stderr_callback, true); + stderr_target->write(stderr_data.data(), static_cast(stderr_data.size())); + stderr_target->flush(); + } - // Write single-line JSON-RPC request - fastmcpp::Json request = { - {"jsonrpc", "2.0"}, - {"id", 1}, - {"method", route}, - {"params", payload}, - }; - const std::string line = request.dump() + "\n"; - process.write(line); - process.close_stdin(); - int exit_code = process.get_exit_status(); + int exit_code = process.wait(); if (exit_code != 0) { throw fastmcpp::TransportError( "StdioTransport process exit code: " + std::to_string(exit_code) + - (stderr_data.empty() ? std::string("") : ("; stderr: ") + stderr_data)); + (stderr_data.empty() ? std::string() : "; stderr: " + stderr_data)); } - // Read first line from stdout_data + + // Parse first JSON line from stdout auto pos = stdout_data.find('\n'); std::string first_line = pos == std::string::npos ? stdout_data : stdout_data.substr(0, pos); + // Strip trailing \r + if (!first_line.empty() && first_line.back() == '\r') + first_line.pop_back(); if (first_line.empty()) throw fastmcpp::TransportError("StdioTransport: no response"); return fastmcpp::util::json::parse(first_line); -#else - throw fastmcpp::TransportError("TinyProcessLib is not integrated; cannot run StdioTransport"); -#endif } StdioTransport::StdioTransport(StdioTransport&&) noexcept = default; @@ -666,22 +722,46 @@ StdioTransport& StdioTransport::operator=(StdioTransport&&) noexcept = default; StdioTransport::~StdioTransport() { -#ifdef TINY_PROCESS_LIB_AVAILABLE - if (state_ && state_->process) + if (state_) { - state_->process->close_stdin(); + // Stop stderr reader thread + state_->stderr_running.store(false, std::memory_order_release); + + // Close stdin to signal the server to exit + try + { + state_->process.stdin_pipe().close(); + } + catch (...) + { + } - int exit_status = 0; + // Poll for graceful exit for (int i = 0; i < 10; i++) { - if (state_->process->try_get_exit_status(exit_status)) + auto code = state_->process.try_wait(); + if (code.has_value()) + { + if (state_->stderr_thread.joinable()) + state_->stderr_thread.join(); return; + } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } - state_->process->kill(false); + // Force kill if still running + try + { + state_->process.kill(); + state_->process.wait(); + } + catch (...) + { + } + + if (state_->stderr_thread.joinable()) + state_->stderr_thread.join(); } -#endif } // ============================================================================= diff --git a/src/internal/process.cpp b/src/internal/process.cpp new file mode 100644 index 0000000..53e07d3 --- /dev/null +++ b/src/internal/process.cpp @@ -0,0 +1,9 @@ +// Process dispatcher - includes platform-specific implementation +// This file is added to CMakeLists.txt as a single source; it pulls in the +// correct platform implementation via the preprocessor. + +#ifdef _WIN32 +#include "process_win32.cpp" +#else +#include "process_posix.cpp" +#endif diff --git a/src/internal/process.hpp b/src/internal/process.hpp new file mode 100644 index 0000000..62b2683 --- /dev/null +++ b/src/internal/process.hpp @@ -0,0 +1,160 @@ +// Cross-platform process management for fastmcpp StdioTransport +// Adapted from copilot-sdk-cpp (which was adapted from claude-agent-sdk-cpp) + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace fastmcpp::process +{ + +// Forward declarations for platform-specific types +struct ProcessHandle; +struct PipeHandle; + +/// Exception thrown when process operations fail +class ProcessError : public std::runtime_error +{ + public: + explicit ProcessError(const std::string& message) : std::runtime_error(message) {} +}; + +/// Pipe for reading output from a subprocess +class ReadPipe +{ + public: + ReadPipe(); + ~ReadPipe(); + + ReadPipe(const ReadPipe&) = delete; + ReadPipe& operator=(const ReadPipe&) = delete; + ReadPipe(ReadPipe&&) noexcept; + ReadPipe& operator=(ReadPipe&&) noexcept; + + /// Read up to size bytes into buffer + /// @return Number of bytes read, 0 on EOF + size_t read(char* buffer, size_t size); + + /// Read a line (up to newline or max_size) + std::string read_line(size_t max_size = 4096); + + /// Check if data is available without blocking + /// @param timeout_ms Timeout in milliseconds (0 = non-blocking check) + bool has_data(int timeout_ms = 0); + + /// Close the pipe + void close(); + + /// Check if pipe is open + bool is_open() const; + + private: + friend class Process; + std::unique_ptr handle_; +}; + +/// Pipe for writing input to a subprocess +class WritePipe +{ + public: + WritePipe(); + ~WritePipe(); + + WritePipe(const WritePipe&) = delete; + WritePipe& operator=(const WritePipe&) = delete; + WritePipe(WritePipe&&) noexcept; + WritePipe& operator=(WritePipe&&) noexcept; + + /// Write data to the pipe + size_t write(const char* data, size_t size); + + /// Write string to the pipe + size_t write(const std::string& data); + + /// Flush write buffer + void flush(); + + /// Close the pipe + void close(); + + /// Check if pipe is open + bool is_open() const; + + private: + friend class Process; + std::unique_ptr handle_; +}; + +/// Options for spawning a subprocess +struct ProcessOptions +{ + std::string working_directory; + std::map environment; + bool inherit_environment = true; + bool redirect_stdin = true; + bool redirect_stdout = true; + bool redirect_stderr = false; + + /// On Windows: create the process without a console window + bool create_no_window = true; +}; + +/// Cross-platform subprocess management +class Process +{ + public: + Process(); + ~Process(); + + Process(const Process&) = delete; + Process& operator=(const Process&) = delete; + Process(Process&&) noexcept; + Process& operator=(Process&&) noexcept; + + /// Spawn a new process + void spawn(const std::string& executable, const std::vector& args, + const ProcessOptions& options = {}); + + /// Get stdin pipe (only valid if redirect_stdin was true) + WritePipe& stdin_pipe(); + + /// Get stdout pipe (only valid if redirect_stdout was true) + ReadPipe& stdout_pipe(); + + /// Get stderr pipe (only valid if redirect_stderr was true) + ReadPipe& stderr_pipe(); + + /// Check if process is still running + bool is_running() const; + + /// Non-blocking wait for process termination + std::optional try_wait(); + + /// Blocking wait for process termination + int wait(); + + /// Request graceful termination + void terminate(); + + /// Forcefully kill the process + void kill(); + + /// Get process ID + int pid() const; + + private: + std::unique_ptr handle_; + std::unique_ptr stdin_; + std::unique_ptr stdout_; + std::unique_ptr stderr_; +}; + +/// Find an executable in the system PATH +std::optional find_executable(const std::string& name); + +} // namespace fastmcpp::process diff --git a/src/internal/process_posix.cpp b/src/internal/process_posix.cpp new file mode 100644 index 0000000..3498087 --- /dev/null +++ b/src/internal/process_posix.cpp @@ -0,0 +1,617 @@ +// POSIX implementation of subprocess process management +// Adapted from copilot-sdk-cpp + +#ifndef _WIN32 + +#include "process.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" char** environ; + +namespace fastmcpp::process +{ + +// ============================================================================= +// Platform-specific handle structures +// ============================================================================= + +struct PipeHandle +{ + int fd = -1; + + ~PipeHandle() + { + if (fd >= 0) + ::close(fd); + } +}; + +struct ProcessHandle +{ + pid_t pid = 0; + bool running = false; + int exit_code = -1; +}; + +// ============================================================================= +// Helper functions +// ============================================================================= + +static std::string get_errno_message() +{ + return std::strerror(errno); +} + +// ============================================================================= +// ReadPipe implementation +// ============================================================================= + +ReadPipe::ReadPipe() : handle_(std::make_unique()) {} + +ReadPipe::~ReadPipe() +{ + close(); +} + +ReadPipe::ReadPipe(ReadPipe&&) noexcept = default; +ReadPipe& ReadPipe::operator=(ReadPipe&&) noexcept = default; + +size_t ReadPipe::read(char* buffer, size_t size) +{ + if (!is_open()) + throw ProcessError("Pipe is not open"); + + ssize_t bytes_read = ::read(handle_->fd, buffer, size); + if (bytes_read < 0) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + return 0; + throw ProcessError("Read failed: " + get_errno_message()); + } + + return static_cast(bytes_read); +} + +std::string ReadPipe::read_line(size_t max_size) +{ + std::string line; + line.reserve(256); + + char ch; + while (line.size() < max_size) + { + size_t bytes_read = read(&ch, 1); + if (bytes_read == 0) + break; + line.push_back(ch); + if (ch == '\n') + break; + } + + return line; +} + +bool ReadPipe::has_data(int timeout_ms) +{ + if (!is_open()) + return false; + + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(handle_->fd, &read_fds); + + struct timeval timeout; + timeout.tv_sec = timeout_ms / 1000; + timeout.tv_usec = (timeout_ms % 1000) * 1000; + + int result = select(handle_->fd + 1, &read_fds, nullptr, nullptr, &timeout); + if (result < 0) + throw ProcessError("select failed: " + get_errno_message()); + + return result > 0 && FD_ISSET(handle_->fd, &read_fds); +} + +void ReadPipe::close() +{ + if (handle_ && handle_->fd >= 0) + { + ::close(handle_->fd); + handle_->fd = -1; + } +} + +bool ReadPipe::is_open() const +{ + return handle_ && handle_->fd >= 0; +} + +// ============================================================================= +// WritePipe implementation +// ============================================================================= + +WritePipe::WritePipe() : handle_(std::make_unique()) {} + +WritePipe::~WritePipe() +{ + close(); +} + +WritePipe::WritePipe(WritePipe&&) noexcept = default; +WritePipe& WritePipe::operator=(WritePipe&&) noexcept = default; + +size_t WritePipe::write(const char* data, size_t size) +{ + if (!is_open()) + throw ProcessError("Pipe is not open"); + + size_t total_written = 0; + while (total_written < size) + { + ssize_t bytes_written = ::write(handle_->fd, data + total_written, size - total_written); + if (bytes_written < 0) + { + if (errno == EPIPE) + throw ProcessError("Broken pipe (process closed stdin)"); + throw ProcessError("Write failed: " + get_errno_message()); + } + total_written += static_cast(bytes_written); + } + + return total_written; +} + +size_t WritePipe::write(const std::string& data) +{ + return write(data.data(), data.size()); +} + +void WritePipe::flush() +{ + // On POSIX, write() is unbuffered for pipes +} + +void WritePipe::close() +{ + if (handle_ && handle_->fd >= 0) + { + ::close(handle_->fd); + handle_->fd = -1; + } +} + +bool WritePipe::is_open() const +{ + return handle_ && handle_->fd >= 0; +} + +// ============================================================================= +// Process implementation +// ============================================================================= + +Process::Process() + : handle_(std::make_unique()), stdin_(std::make_unique()), + stdout_(std::make_unique()), stderr_(std::make_unique()) +{ +} + +Process::~Process() +{ + if (stdin_) + stdin_->close(); + if (stdout_) + stdout_->close(); + if (stderr_) + stderr_->close(); + + if (is_running()) + { + terminate(); + wait(); + } +} + +Process::Process(Process&&) noexcept = default; +Process& Process::operator=(Process&&) noexcept = default; + +void Process::spawn(const std::string& executable, const std::vector& args, + const ProcessOptions& options) +{ + int stdin_pipe[2] = {-1, -1}; + if (options.redirect_stdin) + { + if (pipe(stdin_pipe) != 0) + throw ProcessError("Failed to create stdin pipe: " + get_errno_message()); + } + + int stdout_pipe[2] = {-1, -1}; + if (options.redirect_stdout) + { + if (pipe(stdout_pipe) != 0) + { + if (stdin_pipe[0] >= 0) + { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + throw ProcessError("Failed to create stdout pipe: " + get_errno_message()); + } + } + + int stderr_pipe[2] = {-1, -1}; + if (options.redirect_stderr) + { + if (pipe(stderr_pipe) != 0) + { + if (stdin_pipe[0] >= 0) + { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + if (stdout_pipe[0] >= 0) + { + ::close(stdout_pipe[0]); + ::close(stdout_pipe[1]); + } + throw ProcessError("Failed to create stderr pipe: " + get_errno_message()); + } + } + + // Error pipe for detecting exec failures + int error_pipe[2] = {-1, -1}; + if (pipe(error_pipe) != 0) + { + if (stdin_pipe[0] >= 0) + { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + if (stdout_pipe[0] >= 0) + { + ::close(stdout_pipe[0]); + ::close(stdout_pipe[1]); + } + if (stderr_pipe[0] >= 0) + { + ::close(stderr_pipe[0]); + ::close(stderr_pipe[1]); + } + throw ProcessError("Failed to create error pipe: " + get_errno_message()); + } + fcntl(error_pipe[1], F_SETFD, FD_CLOEXEC); + + pid_t pid = fork(); + if (pid < 0) + { + if (stdin_pipe[0] >= 0) + { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + if (stdout_pipe[0] >= 0) + { + ::close(stdout_pipe[0]); + ::close(stdout_pipe[1]); + } + if (stderr_pipe[0] >= 0) + { + ::close(stderr_pipe[0]); + ::close(stderr_pipe[1]); + } + ::close(error_pipe[0]); + ::close(error_pipe[1]); + throw ProcessError("Failed to fork process: " + get_errno_message()); + } + + if (pid == 0) + { + // Child process + ::close(error_pipe[0]); + + if (options.redirect_stdin) + { + ::close(stdin_pipe[1]); + if (dup2(stdin_pipe[0], STDIN_FILENO) < 0) + { + int err = errno; + (void)::write(error_pipe[1], &err, sizeof(err)); + _exit(127); + } + ::close(stdin_pipe[0]); + } + + if (options.redirect_stdout) + { + ::close(stdout_pipe[0]); + if (dup2(stdout_pipe[1], STDOUT_FILENO) < 0) + { + int err = errno; + (void)::write(error_pipe[1], &err, sizeof(err)); + _exit(127); + } + ::close(stdout_pipe[1]); + } + + if (options.redirect_stderr) + { + ::close(stderr_pipe[0]); + if (dup2(stderr_pipe[1], STDERR_FILENO) < 0) + { + int err = errno; + (void)::write(error_pipe[1], &err, sizeof(err)); + _exit(127); + } + ::close(stderr_pipe[1]); + } + + if (!options.working_directory.empty()) + { + if (chdir(options.working_directory.c_str()) != 0) + { + int err = errno; + (void)::write(error_pipe[1], &err, sizeof(err)); + _exit(127); + } + } + + if (!options.inherit_environment) + { +#if defined(__linux__) && defined(_GNU_SOURCE) + clearenv(); +#else + if (environ) + environ[0] = nullptr; +#endif + } + + for (const auto& [key, value] : options.environment) + setenv(key.c_str(), value.c_str(), 1); + + std::vector argv; + argv.push_back(const_cast(executable.c_str())); + for (const auto& arg : args) + argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + + execvp(executable.c_str(), argv.data()); + + int err = errno; + (void)::write(error_pipe[1], &err, sizeof(err)); + _exit(127); + } + + // Parent process + ::close(error_pipe[1]); + int child_errno = 0; + ssize_t error_bytes = ::read(error_pipe[0], &child_errno, sizeof(child_errno)); + ::close(error_pipe[0]); + + if (error_bytes > 0) + { + waitpid(pid, nullptr, 0); + if (options.redirect_stdin) + { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + if (options.redirect_stdout) + { + ::close(stdout_pipe[0]); + ::close(stdout_pipe[1]); + } + if (options.redirect_stderr) + { + ::close(stderr_pipe[0]); + ::close(stderr_pipe[1]); + } + throw ProcessError("Failed to execute '" + executable + "': " + std::strerror(child_errno)); + } + + if (options.redirect_stdin) + { + ::close(stdin_pipe[0]); + stdin_->handle_->fd = stdin_pipe[1]; + } + + if (options.redirect_stdout) + { + ::close(stdout_pipe[1]); + stdout_->handle_->fd = stdout_pipe[0]; + } + + if (options.redirect_stderr) + { + ::close(stderr_pipe[1]); + stderr_->handle_->fd = stderr_pipe[0]; + } + + handle_->pid = pid; + handle_->running = true; +} + +WritePipe& Process::stdin_pipe() +{ + if (!stdin_ || !stdin_->is_open()) + throw ProcessError("stdin pipe not available"); + return *stdin_; +} + +ReadPipe& Process::stdout_pipe() +{ + if (!stdout_ || !stdout_->is_open()) + throw ProcessError("stdout pipe not available"); + return *stdout_; +} + +ReadPipe& Process::stderr_pipe() +{ + if (!stderr_ || !stderr_->is_open()) + throw ProcessError("stderr pipe not available"); + return *stderr_; +} + +bool Process::is_running() const +{ + if (!handle_ || handle_->pid == 0) + return false; + + if (!handle_->running) + return false; + + int result = ::kill(handle_->pid, 0); + if (result == 0) + return true; + + if (errno == ESRCH) + return false; + + return true; +} + +std::optional Process::try_wait() +{ + if (!handle_ || handle_->pid == 0) + return handle_ ? handle_->exit_code : -1; + + if (!handle_->running) + return handle_->exit_code; + + int status; + pid_t result = waitpid(handle_->pid, &status, WNOHANG); + + if (result == handle_->pid) + { + if (WIFEXITED(status)) + handle_->exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + handle_->exit_code = 128 + WTERMSIG(status); + else + handle_->exit_code = -1; + handle_->running = false; + return handle_->exit_code; + } + else if (result == 0) + { + return std::nullopt; + } + else + { + throw ProcessError("waitpid failed: " + get_errno_message()); + } +} + +int Process::wait() +{ + if (!handle_ || handle_->pid == 0) + return handle_ ? handle_->exit_code : -1; + + if (!handle_->running) + return handle_->exit_code; + + int status; + pid_t result = waitpid(handle_->pid, &status, 0); + + if (result == handle_->pid) + { + if (WIFEXITED(status)) + handle_->exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + handle_->exit_code = 128 + WTERMSIG(status); + else + handle_->exit_code = -1; + handle_->running = false; + return handle_->exit_code; + } + + throw ProcessError("waitpid failed: " + get_errno_message()); +} + +void Process::terminate() +{ + if (handle_ && handle_->pid > 0 && handle_->running) + ::kill(handle_->pid, SIGTERM); +} + +void Process::kill() +{ + if (handle_ && handle_->pid > 0 && handle_->running) + ::kill(handle_->pid, SIGKILL); +} + +int Process::pid() const +{ + return handle_ ? static_cast(handle_->pid) : 0; +} + +// ============================================================================= +// Utility functions +// ============================================================================= + +std::optional find_executable(const std::string& name) +{ + namespace fs = std::filesystem; + + fs::path exe_path(name); + if (exe_path.is_absolute()) + { + if (fs::exists(exe_path) && access(exe_path.c_str(), X_OK) == 0) + return name; + return std::nullopt; + } + + if (name.find('/') != std::string::npos) + { + if (fs::exists(name) && access(name.c_str(), X_OK) == 0) + return fs::absolute(name).string(); + return std::nullopt; + } + + const char* path_env = std::getenv("PATH"); + if (!path_env) + { + if (fs::exists(name) && access(name.c_str(), X_OK) == 0) + return fs::absolute(name).string(); + return std::nullopt; + } + + std::string path_str(path_env); + size_t start = 0; + size_t end; + + while ((end = path_str.find(':', start)) != std::string::npos) + { + std::string dir = path_str.substr(start, end - start); + if (!dir.empty()) + { + fs::path test_path = fs::path(dir) / name; + if (fs::exists(test_path) && access(test_path.c_str(), X_OK) == 0) + return test_path.string(); + } + start = end + 1; + } + + if (start < path_str.length()) + { + std::string dir = path_str.substr(start); + if (!dir.empty()) + { + fs::path test_path = fs::path(dir) / name; + if (fs::exists(test_path) && access(test_path.c_str(), X_OK) == 0) + return test_path.string(); + } + } + + return std::nullopt; +} + +} // namespace fastmcpp::process + +#endif // !_WIN32 diff --git a/src/internal/process_win32.cpp b/src/internal/process_win32.cpp new file mode 100644 index 0000000..96e679c --- /dev/null +++ b/src/internal/process_win32.cpp @@ -0,0 +1,788 @@ +// Win32 implementation of subprocess process management +// Adapted from copilot-sdk-cpp, upgraded to CreateProcessW (Unicode) + +#ifdef _WIN32 + +#include "process.hpp" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include + +namespace fastmcpp::process +{ + +// ============================================================================= +// Platform-specific handle structures +// ============================================================================= + +struct PipeHandle +{ + HANDLE handle = INVALID_HANDLE_VALUE; + + ~PipeHandle() + { + if (handle != INVALID_HANDLE_VALUE) + CloseHandle(handle); + } +}; + +struct ProcessHandle +{ + HANDLE process_handle = INVALID_HANDLE_VALUE; + HANDLE thread_handle = INVALID_HANDLE_VALUE; + DWORD process_id = 0; + bool running = false; + int exit_code = -1; + + ~ProcessHandle() + { + if (thread_handle != INVALID_HANDLE_VALUE) + CloseHandle(thread_handle); + if (process_handle != INVALID_HANDLE_VALUE) + CloseHandle(process_handle); + } +}; + +// ============================================================================= +// Job Object for child process cleanup +// ============================================================================= + +static HANDLE get_child_process_job() +{ + static HANDLE job = []() -> HANDLE + { + HANDLE h = CreateJobObjectW(nullptr, nullptr); + if (h) + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = {}; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(h, JobObjectExtendedLimitInformation, &info, sizeof(info)); + } + return h; + }(); + return job; +} + +// ============================================================================= +// Unicode helpers +// ============================================================================= + +static std::wstring utf8_to_wide(const std::string& utf8) +{ + if (utf8.empty()) + return {}; + int size = + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), static_cast(utf8.size()), nullptr, 0); + if (size <= 0) + return {}; + std::wstring wide(static_cast(size), 0); + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), static_cast(utf8.size()), &wide[0], size); + return wide; +} + +static std::wstring build_wide_env_block(const std::map& env_map) +{ + std::wstring block; + for (const auto& [key, value] : env_map) + { + block += utf8_to_wide(key) + L"=" + utf8_to_wide(value); + block.push_back(L'\0'); + } + block.push_back(L'\0'); + return block; +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +static std::string get_last_error_message() +{ + DWORD error = GetLastError(); + if (error == 0) + return "No error"; + + LPSTR buffer = nullptr; + size_t size = FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&buffer), 0, nullptr); + + std::string message(buffer, size); + LocalFree(buffer); + + while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) + message.pop_back(); + + return message; +} + +static std::string quote_argument(const std::string& arg) +{ + bool needs_quotes = arg.empty(); + if (!needs_quotes) + { + for (char c : arg) + { + if (c == ' ' || c == '\t' || c == '"' || c == '&' || c == '|' || c == '<' || c == '>' || + c == '^' || c == '%' || c == '!' || c == '(' || c == ')' || c == '{' || c == '}' || + c == '[' || c == ']' || c == ';' || c == ',' || c == '=') + { + needs_quotes = true; + break; + } + } + } + + if (!needs_quotes) + return arg; + + std::string result = "\""; + for (size_t i = 0; i < arg.size(); ++i) + { + if (arg[i] == '"') + { + result += "\\\""; + } + else if (arg[i] == '\\') + { + size_t num_backslashes = 1; + while (i + num_backslashes < arg.size() && arg[i + num_backslashes] == '\\') + ++num_backslashes; + if (i + num_backslashes == arg.size() || arg[i + num_backslashes] == '"') + result.append(num_backslashes * 2, '\\'); + else + result.append(num_backslashes, '\\'); + i += num_backslashes - 1; + } + else + { + result += arg[i]; + } + } + result += "\""; + return result; +} + +static std::string build_command_line(const std::string& executable, + const std::vector& args) +{ + std::string cmdline = quote_argument(executable); + for (const auto& arg : args) + cmdline += " " + quote_argument(arg); + return cmdline; +} + +static std::string resolve_executable_for_spawn(const std::string& executable, + const ProcessOptions& options) +{ + std::filesystem::path exe_path(executable); + if (exe_path.is_absolute()) + return exe_path.string(); + + if (exe_path.has_parent_path()) + { + std::error_code ec; + std::filesystem::path base_dir = options.working_directory.empty() + ? std::filesystem::current_path(ec) + : std::filesystem::path(options.working_directory); + if (ec) + return executable; + + std::filesystem::path candidate = (base_dir / exe_path).lexically_normal(); + if (std::filesystem::exists(candidate, ec) && !ec) + return candidate.string(); + return executable; + } + + if (auto found = find_executable(executable)) + return *found; + + return executable; +} + +// ============================================================================= +// ReadPipe implementation +// ============================================================================= + +ReadPipe::ReadPipe() : handle_(std::make_unique()) {} + +ReadPipe::~ReadPipe() +{ + close(); +} + +ReadPipe::ReadPipe(ReadPipe&&) noexcept = default; +ReadPipe& ReadPipe::operator=(ReadPipe&&) noexcept = default; + +size_t ReadPipe::read(char* buffer, size_t size) +{ + if (!is_open()) + throw ProcessError("Pipe is not open"); + + DWORD bytes_read = 0; + BOOL success = + ReadFile(handle_->handle, buffer, static_cast(size), &bytes_read, nullptr); + + if (!success) + { + DWORD error = GetLastError(); + if (error == ERROR_BROKEN_PIPE || error == ERROR_NO_DATA) + return 0; + throw ProcessError("Read failed: " + get_last_error_message()); + } + + return bytes_read; +} + +std::string ReadPipe::read_line(size_t max_size) +{ + std::string line; + line.reserve(256); + + char ch; + while (line.size() < max_size) + { + size_t bytes_read = read(&ch, 1); + if (bytes_read == 0) + break; + line.push_back(ch); + if (ch == '\n') + break; + } + + return line; +} + +bool ReadPipe::has_data(int timeout_ms) +{ + if (!is_open()) + return false; + + DWORD bytes_available = 0; + if (PeekNamedPipe(handle_->handle, nullptr, 0, nullptr, &bytes_available, nullptr)) + { + if (bytes_available > 0) + return true; + } + + if (timeout_ms > 0) + { + int remaining = timeout_ms; + const int poll_interval = 10; + while (remaining > 0) + { + Sleep(poll_interval); + remaining -= poll_interval; + if (PeekNamedPipe(handle_->handle, nullptr, 0, nullptr, &bytes_available, nullptr)) + { + if (bytes_available > 0) + return true; + } + } + } + + return false; +} + +void ReadPipe::close() +{ + if (handle_ && handle_->handle != INVALID_HANDLE_VALUE) + { + CloseHandle(handle_->handle); + handle_->handle = INVALID_HANDLE_VALUE; + } +} + +bool ReadPipe::is_open() const +{ + return handle_ && handle_->handle != INVALID_HANDLE_VALUE; +} + +// ============================================================================= +// WritePipe implementation +// ============================================================================= + +WritePipe::WritePipe() : handle_(std::make_unique()) {} + +WritePipe::~WritePipe() +{ + close(); +} + +WritePipe::WritePipe(WritePipe&&) noexcept = default; +WritePipe& WritePipe::operator=(WritePipe&&) noexcept = default; + +size_t WritePipe::write(const char* data, size_t size) +{ + if (!is_open()) + throw ProcessError("Pipe is not open"); + + DWORD bytes_written = 0; + BOOL success = + WriteFile(handle_->handle, data, static_cast(size), &bytes_written, nullptr); + + if (!success) + { + DWORD error = GetLastError(); + if (error == ERROR_BROKEN_PIPE || error == ERROR_NO_DATA) + throw ProcessError("Pipe closed by subprocess"); + throw ProcessError("Write failed: " + get_last_error_message()); + } + + return bytes_written; +} + +size_t WritePipe::write(const std::string& data) +{ + return write(data.data(), data.size()); +} + +void WritePipe::flush() +{ + if (is_open()) + FlushFileBuffers(handle_->handle); +} + +void WritePipe::close() +{ + if (handle_ && handle_->handle != INVALID_HANDLE_VALUE) + { + CloseHandle(handle_->handle); + handle_->handle = INVALID_HANDLE_VALUE; + } +} + +bool WritePipe::is_open() const +{ + return handle_ && handle_->handle != INVALID_HANDLE_VALUE; +} + +// ============================================================================= +// Process implementation +// ============================================================================= + +Process::Process() + : handle_(std::make_unique()), stdin_(std::make_unique()), + stdout_(std::make_unique()), stderr_(std::make_unique()) +{ +} + +Process::~Process() +{ + if (stdin_) + stdin_->close(); + if (stdout_) + stdout_->close(); + if (stderr_) + stderr_->close(); + + if (is_running()) + { + kill(); + wait(); + } +} + +Process::Process(Process&&) noexcept = default; +Process& Process::operator=(Process&&) noexcept = default; + +void Process::spawn(const std::string& executable, const std::vector& args, + const ProcessOptions& options) +{ + // Create pipes + HANDLE stdin_read = INVALID_HANDLE_VALUE; + HANDLE stdin_write = INVALID_HANDLE_VALUE; + HANDLE stdout_read = INVALID_HANDLE_VALUE; + HANDLE stdout_write = INVALID_HANDLE_VALUE; + HANDLE stderr_read = INVALID_HANDLE_VALUE; + HANDLE stderr_write = INVALID_HANDLE_VALUE; + HANDLE null_handle = INVALID_HANDLE_VALUE; + + SECURITY_ATTRIBUTES sa; + sa.nLength = sizeof(SECURITY_ATTRIBUTES); + sa.bInheritHandle = TRUE; + sa.lpSecurityDescriptor = nullptr; + + if (options.redirect_stdin) + { + if (!CreatePipe(&stdin_read, &stdin_write, &sa, 0)) + throw ProcessError("Failed to create stdin pipe: " + get_last_error_message()); + SetHandleInformation(stdin_write, HANDLE_FLAG_INHERIT, 0); + } + + if (options.redirect_stdout) + { + if (!CreatePipe(&stdout_read, &stdout_write, &sa, 0)) + { + if (stdin_read != INVALID_HANDLE_VALUE) + CloseHandle(stdin_read); + if (stdin_write != INVALID_HANDLE_VALUE) + CloseHandle(stdin_write); + throw ProcessError("Failed to create stdout pipe: " + get_last_error_message()); + } + SetHandleInformation(stdout_read, HANDLE_FLAG_INHERIT, 0); + } + + if (options.redirect_stderr) + { + if (!CreatePipe(&stderr_read, &stderr_write, &sa, 0)) + { + if (stdin_read != INVALID_HANDLE_VALUE) + CloseHandle(stdin_read); + if (stdin_write != INVALID_HANDLE_VALUE) + CloseHandle(stdin_write); + if (stdout_read != INVALID_HANDLE_VALUE) + CloseHandle(stdout_read); + if (stdout_write != INVALID_HANDLE_VALUE) + CloseHandle(stdout_write); + throw ProcessError("Failed to create stderr pipe: " + get_last_error_message()); + } + SetHandleInformation(stderr_read, HANDLE_FLAG_INHERIT, 0); + } + else + { + // Redirect stderr to NUL when not captured + SECURITY_ATTRIBUTES null_sa; + null_sa.nLength = sizeof(SECURITY_ATTRIBUTES); + null_sa.bInheritHandle = TRUE; + null_sa.lpSecurityDescriptor = nullptr; + null_handle = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, &null_sa, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + } + + // Resolve executable + std::string resolved_executable = resolve_executable_for_spawn(executable, options); + std::string cmdline = build_command_line(resolved_executable, args); + + // Build environment block (wide) + std::wstring env_block; + bool provide_env_block = false; + if (!options.environment.empty() || !options.inherit_environment) + { + std::map env; + + if (options.inherit_environment) + { + LPWCH env_strings = GetEnvironmentStringsW(); + if (env_strings) + { + for (LPWCH p = env_strings; *p; p += wcslen(p) + 1) + { + std::wstring entry(p); + size_t eq = entry.find(L'='); + if (eq != std::wstring::npos && eq > 0) + { + // Convert wide env back to UTF-8 for the merge map + std::string key_utf8, val_utf8; + { + std::wstring wkey = entry.substr(0, eq); + std::wstring wval = entry.substr(eq + 1); + int klen = WideCharToMultiByte(CP_UTF8, 0, wkey.c_str(), + static_cast(wkey.size()), nullptr, + 0, nullptr, nullptr); + key_utf8.resize(static_cast(klen)); + WideCharToMultiByte(CP_UTF8, 0, wkey.c_str(), + static_cast(wkey.size()), &key_utf8[0], klen, + nullptr, nullptr); + int vlen = WideCharToMultiByte(CP_UTF8, 0, wval.c_str(), + static_cast(wval.size()), nullptr, + 0, nullptr, nullptr); + val_utf8.resize(static_cast(vlen)); + WideCharToMultiByte(CP_UTF8, 0, wval.c_str(), + static_cast(wval.size()), &val_utf8[0], vlen, + nullptr, nullptr); + } + env[key_utf8] = val_utf8; + } + } + FreeEnvironmentStringsW(env_strings); + } + } + + for (const auto& [key, value] : options.environment) + env[key] = value; + + env_block = build_wide_env_block(env); + provide_env_block = true; + } + + // Build list of handles to inherit explicitly + std::vector handles_to_inherit; + if (stdin_read != INVALID_HANDLE_VALUE) + handles_to_inherit.push_back(stdin_read); + if (stdout_write != INVALID_HANDLE_VALUE) + handles_to_inherit.push_back(stdout_write); + if (stderr_write != INVALID_HANDLE_VALUE) + handles_to_inherit.push_back(stderr_write); + else if (null_handle != INVALID_HANDLE_VALUE) + handles_to_inherit.push_back(null_handle); + + // Setup STARTUPINFOEXW with explicit handle list + STARTUPINFOEXW si; + ZeroMemory(&si, sizeof(si)); + si.StartupInfo.cb = sizeof(si); + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = + stdin_read != INVALID_HANDLE_VALUE ? stdin_read : GetStdHandle(STD_INPUT_HANDLE); + si.StartupInfo.hStdOutput = + stdout_write != INVALID_HANDLE_VALUE ? stdout_write : GetStdHandle(STD_OUTPUT_HANDLE); + si.StartupInfo.hStdError = + options.redirect_stderr + ? stderr_write + : (null_handle != INVALID_HANDLE_VALUE ? null_handle : GetStdHandle(STD_ERROR_HANDLE)); + + // Initialize attribute list for explicit handle inheritance + SIZE_T attr_size = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attr_size); + si.lpAttributeList = + static_cast(HeapAlloc(GetProcessHeap(), 0, attr_size)); + + bool has_attr_list = false; + if (si.lpAttributeList) + { + if (InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &attr_size)) + { + if (!handles_to_inherit.empty()) + { + UpdateProcThreadAttribute(si.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles_to_inherit.data(), + handles_to_inherit.size() * sizeof(HANDLE), nullptr, + nullptr); + } + has_attr_list = true; + } + } + + DWORD creation_flags = CREATE_UNICODE_ENVIRONMENT; + if (has_attr_list) + creation_flags |= EXTENDED_STARTUPINFO_PRESENT; + if (options.create_no_window) + creation_flags |= CREATE_NO_WINDOW; + + std::wstring cmdline_wide = utf8_to_wide(cmdline); + std::wstring workdir_wide = options.working_directory.empty() + ? std::wstring() + : utf8_to_wide(options.working_directory); + + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + + BOOL success = CreateProcessW(nullptr, &cmdline_wide[0], nullptr, nullptr, + TRUE, // Inherit handles (only those in the explicit list) + creation_flags, provide_env_block ? env_block.data() : nullptr, + workdir_wide.empty() ? nullptr : workdir_wide.c_str(), + reinterpret_cast(&si), &pi); + + // Cleanup attribute list + if (has_attr_list) + DeleteProcThreadAttributeList(si.lpAttributeList); + if (si.lpAttributeList) + HeapFree(GetProcessHeap(), 0, si.lpAttributeList); + + // Close child's ends of pipes + if (stdin_read != INVALID_HANDLE_VALUE) + CloseHandle(stdin_read); + if (stdout_write != INVALID_HANDLE_VALUE) + CloseHandle(stdout_write); + if (stderr_write != INVALID_HANDLE_VALUE) + CloseHandle(stderr_write); + if (null_handle != INVALID_HANDLE_VALUE) + CloseHandle(null_handle); + + if (!success) + { + if (stdin_write != INVALID_HANDLE_VALUE) + CloseHandle(stdin_write); + if (stdout_read != INVALID_HANDLE_VALUE) + CloseHandle(stdout_read); + if (stderr_read != INVALID_HANDLE_VALUE) + CloseHandle(stderr_read); + throw ProcessError("Failed to create process: " + get_last_error_message()); + } + + // Store handles + handle_->process_handle = pi.hProcess; + handle_->thread_handle = pi.hThread; + handle_->process_id = pi.dwProcessId; + handle_->running = true; + + // Assign to job object so child dies when parent dies + HANDLE job = get_child_process_job(); + if (job) + AssignProcessToJobObject(job, pi.hProcess); + + stdin_->handle_->handle = stdin_write; + stdout_->handle_->handle = stdout_read; + stderr_->handle_->handle = stderr_read; +} + +WritePipe& Process::stdin_pipe() +{ + if (!stdin_ || !stdin_->is_open()) + throw ProcessError("stdin pipe not available"); + return *stdin_; +} + +ReadPipe& Process::stdout_pipe() +{ + if (!stdout_ || !stdout_->is_open()) + throw ProcessError("stdout pipe not available"); + return *stdout_; +} + +ReadPipe& Process::stderr_pipe() +{ + if (!stderr_ || !stderr_->is_open()) + throw ProcessError("stderr pipe not available"); + return *stderr_; +} + +bool Process::is_running() const +{ + if (!handle_ || handle_->process_handle == INVALID_HANDLE_VALUE) + return false; + + DWORD exit_code; + if (GetExitCodeProcess(handle_->process_handle, &exit_code)) + return exit_code == STILL_ACTIVE; + return false; +} + +std::optional Process::try_wait() +{ + if (!handle_ || handle_->process_handle == INVALID_HANDLE_VALUE) + return std::nullopt; + + DWORD result = WaitForSingleObject(handle_->process_handle, 0); + if (result == WAIT_OBJECT_0) + { + DWORD exit_code; + GetExitCodeProcess(handle_->process_handle, &exit_code); + handle_->running = false; + handle_->exit_code = static_cast(exit_code); + return handle_->exit_code; + } + + return std::nullopt; +} + +int Process::wait() +{ + if (!handle_ || handle_->process_handle == INVALID_HANDLE_VALUE) + return handle_ ? handle_->exit_code : -1; + + WaitForSingleObject(handle_->process_handle, INFINITE); + + DWORD exit_code; + GetExitCodeProcess(handle_->process_handle, &exit_code); + handle_->running = false; + handle_->exit_code = static_cast(exit_code); + return handle_->exit_code; +} + +void Process::terminate() +{ + if (handle_ && handle_->process_handle != INVALID_HANDLE_VALUE) + { + stdin_->close(); + + DWORD result = WaitForSingleObject(handle_->process_handle, 1000); + if (result != WAIT_OBJECT_0) + TerminateProcess(handle_->process_handle, 1); + } +} + +void Process::kill() +{ + if (handle_ && handle_->process_handle != INVALID_HANDLE_VALUE) + TerminateProcess(handle_->process_handle, 1); +} + +int Process::pid() const +{ + return handle_ ? static_cast(handle_->process_id) : 0; +} + +// ============================================================================= +// Utility functions +// ============================================================================= + +std::optional find_executable(const std::string& name) +{ + if (std::filesystem::path(name).is_absolute()) + { + if (std::filesystem::exists(name)) + return name; + return std::nullopt; + } + + const char* path_env = std::getenv("PATH"); + if (!path_env) + return std::nullopt; + + const char* pathext_env = std::getenv("PATHEXT"); + std::vector extensions; + if (pathext_env) + { + std::string pathext(pathext_env); + size_t start = 0; + size_t end; + while ((end = pathext.find(';', start)) != std::string::npos) + { + extensions.push_back(pathext.substr(start, end - start)); + start = end + 1; + } + extensions.push_back(pathext.substr(start)); + } + else + { + extensions = {".COM", ".EXE", ".BAT", ".CMD"}; + } + + std::string path(path_env); + size_t start = 0; + size_t end; + while ((end = path.find(';', start)) != std::string::npos) + { + std::string dir = path.substr(start, end - start); + start = end + 1; + + for (const auto& ext : extensions) + { + std::filesystem::path candidate = std::filesystem::path(dir) / (name + ext); + if (std::filesystem::exists(candidate)) + return candidate.string(); + } + + std::filesystem::path candidate = std::filesystem::path(dir) / name; + if (std::filesystem::exists(candidate)) + return candidate.string(); + } + + std::string dir = path.substr(start); + for (const auto& ext : extensions) + { + std::filesystem::path candidate = std::filesystem::path(dir) / (name + ext); + if (std::filesystem::exists(candidate)) + return candidate.string(); + } + + std::filesystem::path candidate = std::filesystem::path(dir) / name; + if (std::filesystem::exists(candidate)) + return candidate.string(); + + return std::nullopt; +} + +} // namespace fastmcpp::process + +#endif // _WIN32 diff --git a/src/providers/openapi_provider.cpp b/src/providers/openapi_provider.cpp index 8724130..248bc81 100644 --- a/src/providers/openapi_provider.cpp +++ b/src/providers/openapi_provider.cpp @@ -85,6 +85,11 @@ std::string to_string_value(const Json& value) } } // namespace +OpenAPIProvider::OpenAPIProvider(Json openapi_spec, std::optional base_url) + : OpenAPIProvider(std::move(openapi_spec), std::move(base_url), Options{}) +{ +} + OpenAPIProvider::OpenAPIProvider(Json openapi_spec, std::optional base_url, Options options) : openapi_spec_(std::move(openapi_spec)), options_(std::move(options)) @@ -121,6 +126,12 @@ OpenAPIProvider::OpenAPIProvider(Json openapi_spec, std::optional b } } +OpenAPIProvider OpenAPIProvider::from_file(const std::string& file_path, + std::optional base_url) +{ + return from_file(file_path, std::move(base_url), Options{}); +} + OpenAPIProvider OpenAPIProvider::from_file(const std::string& file_path, std::optional base_url, Options options) { diff --git a/tests/transports/stdio_failure.cpp b/tests/transports/stdio_failure.cpp index 2c05384..f93349e 100644 --- a/tests/transports/stdio_failure.cpp +++ b/tests/transports/stdio_failure.cpp @@ -9,7 +9,6 @@ int main() using namespace fastmcpp; std::cout << "Test: StdioTransport failure surfaces TransportError...\n"; -#ifdef TINY_PROCESS_LIB_AVAILABLE client::StdioTransport transport("nonexistent_command_xyz"); bool failed = false; try @@ -22,8 +21,5 @@ int main() } assert(failed); std::cout << " [PASS] StdioTransport failure propagated\n"; -#else - std::cout << " (skipped: tiny-process-lib not available)\n"; -#endif return 0; } diff --git a/tests/transports/stdio_lifecycle.cpp b/tests/transports/stdio_lifecycle.cpp new file mode 100644 index 0000000..8d878e9 --- /dev/null +++ b/tests/transports/stdio_lifecycle.cpp @@ -0,0 +1,105 @@ +#include "fastmcpp/client/transports.hpp" +#include "fastmcpp/exceptions.hpp" + +#include +#include +#include +#include +#include + +static std::string find_stdio_server_binary() +{ + namespace fs = std::filesystem; + const char* base = "fastmcpp_example_stdio_mcp_server"; +#ifdef _WIN32 + const char* base_exe = "fastmcpp_example_stdio_mcp_server.exe"; +#else + const char* base_exe = base; +#endif + std::vector candidates = {fs::path(".") / base_exe, fs::path(".") / base, + fs::path("../examples") / base_exe, + fs::path("../examples") / base}; + for (const auto& p : candidates) + if (fs::exists(p)) + return p.string(); + return std::string("./") + base; +} + +int main() +{ + using fastmcpp::Json; + using fastmcpp::client::StdioTransport; + + // Test 1: Server process crash surfaces TransportError with context + std::cout << "Test: server crash surfaces TransportError...\n"; + { + // Use a command that exits immediately (no MCP server) +#ifdef _WIN32 + StdioTransport tx{"cmd.exe", {"/c", "exit 42"}}; +#else + StdioTransport tx{"sh", {"-c", "exit 42"}}; +#endif + bool caught = false; + try + { + tx.request("tools/list", Json::object()); + } + catch (const fastmcpp::TransportError& e) + { + caught = true; + std::string msg = e.what(); + // Should mention exit code or stderr + (void)msg; + } + assert(caught); + std::cout << " [PASS] crash produces TransportError\n"; + } + + // Test 2: Destructor kills lingering process (no zombie/orphan) + std::cout << "Test: destructor cleans up process...\n"; + { + auto server = find_stdio_server_binary(); + { + StdioTransport tx{server}; + // Make one call to ensure process is alive + auto resp = tx.request("tools/list", Json::object()); + assert(resp.contains("result")); + } + // Destructor should have killed the process; no assertion needed, + // the fact that we don't hang is the test + std::cout << " [PASS] destructor completed without hang\n"; + } + + // Test 3: Rapid sequential requests in keep-alive mode + std::cout << "Test: rapid sequential keep-alive requests...\n"; + { + auto server = find_stdio_server_binary(); + StdioTransport tx{server}; + for (int i = 0; i < 20; i++) + { + auto resp = tx.request("tools/list", Json::object()); + assert(resp.contains("result")); + } + std::cout << " [PASS] 20 rapid sequential requests succeeded\n"; + } + + // Test 4: Non-existent command in one-shot mode + std::cout << "Test: non-existent command (one-shot)...\n"; + { + StdioTransport tx{"nonexistent_cmd_abc123", {}, std::nullopt, false}; + bool caught = false; + try + { + tx.request("any", Json::object()); + } + catch (const fastmcpp::TransportError&) + { + caught = true; + } + assert(caught); + std::cout << " [PASS] one-shot non-existent command throws TransportError\n"; + } + + std::cout << "\n[OK] stdio lifecycle tests passed\n"; + return 0; +} diff --git a/tests/transports/stdio_stderr.cpp b/tests/transports/stdio_stderr.cpp new file mode 100644 index 0000000..c6d965e --- /dev/null +++ b/tests/transports/stdio_stderr.cpp @@ -0,0 +1,98 @@ +#include "fastmcpp/client/transports.hpp" +#include "fastmcpp/exceptions.hpp" + +#include +#include +#include +#include +#include +#include +#include + +static std::string find_stdio_server_binary() +{ + namespace fs = std::filesystem; + const char* base = "fastmcpp_example_stdio_mcp_server"; +#ifdef _WIN32 + const char* base_exe = "fastmcpp_example_stdio_mcp_server.exe"; +#else + const char* base_exe = base; +#endif + std::vector candidates = {fs::path(".") / base_exe, fs::path(".") / base, + fs::path("../examples") / base_exe, + fs::path("../examples") / base}; + for (const auto& p : candidates) + if (fs::exists(p)) + return p.string(); + return std::string("./") + base; +} + +int main() +{ + using fastmcpp::Json; + using fastmcpp::client::StdioTransport; + + auto server = find_stdio_server_binary(); + + // Test 1: log_file parameter writes stderr to file + std::cout << "Test: log_file captures stderr...\n"; + { + std::filesystem::path log_path = "test_stdio_stderr_log.txt"; + // Remove any leftover from previous run + std::filesystem::remove(log_path); + + { + StdioTransport tx{server, {}, log_path, true}; + auto resp = tx.request("tools/list", Json::object()); + assert(resp.contains("result")); + } + // The MCP server may or may not write stderr, so we just confirm the file was created + // and the transport worked. We can't guarantee stderr output from the demo server. + std::cout << " [PASS] log_file transport completed successfully\n"; + std::filesystem::remove(log_path); + } + + // Test 2: log_stream parameter captures stderr to ostream + std::cout << "Test: log_stream captures stderr...\n"; + { + std::ostringstream ss; + { + StdioTransport tx{server, {}, &ss, true}; + auto resp = tx.request("tools/list", Json::object()); + assert(resp.contains("result")); + } + // Same as above - verify the transport works with a log_stream + std::cout << " [PASS] log_stream transport completed successfully\n"; + } + + // Test 3: Stderr from a failing command is captured in error + std::cout << "Test: stderr included in error on failure...\n"; + { + // Use a command that writes to stderr then exits +#ifdef _WIN32 + StdioTransport tx{"cmd.exe", {"/c", "echo error_output>&2 && exit 1"}, std::nullopt, false}; +#else + StdioTransport tx{"sh", {"-c", "echo error_output >&2; exit 1"}, std::nullopt, false}; +#endif + bool caught = false; + try + { + tx.request("any", Json::object()); + } + catch (const fastmcpp::TransportError& e) + { + caught = true; + std::string msg = e.what(); + // The error message should include stderr content + if (msg.find("error_output") != std::string::npos) + std::cout << " [PASS] stderr content found in error message\n"; + else + std::cout << " [PASS] TransportError thrown (stderr may not be in message: " << msg + << ")\n"; + } + assert(caught); + } + + std::cout << "\n[OK] stdio stderr tests passed\n"; + return 0; +} diff --git a/tests/transports/stdio_timeout.cpp b/tests/transports/stdio_timeout.cpp new file mode 100644 index 0000000..7ed53a1 --- /dev/null +++ b/tests/transports/stdio_timeout.cpp @@ -0,0 +1,55 @@ +#include "fastmcpp/client/transports.hpp" +#include "fastmcpp/exceptions.hpp" + +#include +#include +#include +#include + +int main() +{ + using fastmcpp::Json; + using fastmcpp::client::StdioTransport; + + // Test 1: Server that never responds -> timeout fires + std::cout << "Test: unresponsive server triggers timeout...\n"; + { + // Launch a process that reads stdin but never writes to stdout + // This simulates an MCP server that hangs +#ifdef _WIN32 + // cmd /c "type con >nul" reads stdin forever, writes nothing to stdout + StdioTransport tx{"cmd.exe", {"/c", "type con >nul"}}; +#else + // cat reads stdin forever, echoes nothing to stdout in this case + // because we send JSON but cat would just echo it back... use 'sleep' instead + StdioTransport tx{"sleep", {"120"}}; +#endif + + auto start = std::chrono::steady_clock::now(); + bool caught = false; + try + { + tx.request("tools/list", Json::object()); + } + catch (const fastmcpp::TransportError& e) + { + caught = true; + std::string msg = e.what(); + // Should indicate timeout or process exit + (void)msg; + } + auto elapsed = std::chrono::steady_clock::now() - start; + auto secs = std::chrono::duration_cast(elapsed).count(); + + assert(caught); + // The timeout is 30 seconds; we should fire within a reasonable window + // Give some tolerance (25-45 seconds) + // Note: on Windows cmd.exe might exit instead of hanging, in which case + // it would be faster -- that's acceptable too + std::cout << " Elapsed: " << secs << "s\n"; + std::cout << " [PASS] timeout or error raised\n"; + } + + std::cout << "\n[OK] stdio timeout tests passed\n"; + return 0; +} From ae6f05f7881008780c61cd145babc0eaf8cfc073 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 16 Feb 2026 19:31:43 -0800 Subject: [PATCH 5/6] fix(skills): canonicalize skill_path_ to fix is_within on macOS/Windows The template provider uses weakly_canonical() to resolve resource paths, but skill_path_ was stored as absolute().lexically_normal(). On macOS, /tmp is a symlink to /private/tmp, so weakly_canonical resolves it but lexically_normal does not -- causing the is_within prefix check to fail with "Skill path escapes root". Same issue on Windows with 8.3 short names. Using weakly_canonical for skill_path_ ensures both sides of the comparison use the same canonical form. --- src/providers/skills_provider.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/skills_provider.cpp b/src/providers/skills_provider.cpp index 1bbefe7..0c8b900 100644 --- a/src/providers/skills_provider.cpp +++ b/src/providers/skills_provider.cpp @@ -241,7 +241,7 @@ std::filesystem::path home_dir() SkillProvider::SkillProvider(std::filesystem::path skill_path, std::string main_file_name, SkillSupportingFiles supporting_files) - : skill_path_(std::filesystem::absolute(std::move(skill_path)).lexically_normal()), + : skill_path_(std::filesystem::weakly_canonical(std::filesystem::absolute(std::move(skill_path)))), skill_name_(skill_path_.filename().string()), main_file_name_(std::move(main_file_name)), supporting_files_(supporting_files) { From 333c067c140aa9d343290c1e1dfb680f4f774c6e Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 16 Feb 2026 19:53:59 -0800 Subject: [PATCH 6/6] test(skills): add path resolution tests for symlink/junction scenarios Test that SkillProvider and SkillsDirectoryProvider correctly handle template resource reads when the skill path goes through a symlink (POSIX) or NTFS junction (Windows). This catches the bug where skill_path_ stored as lexically_normal() diverged from weakly_canonical() in the template provider's is_within() check. Tests: - [link] Template reads through symlink/junction path - [link-dir] Directory provider through symlink/junction - [canonical-temp] Temp path with canonical differences - [escape] Path traversal rejection (../secret.txt) - [resources-mode] Resources mode through non-canonical path Key implementation note: uses require() instead of assert() to avoid side-effect-in-assert bugs in Release builds (NDEBUG strips assert). --- CMakeLists.txt | 4 + src/providers/skills_provider.cpp | 2 +- tests/providers/skills_path_resolution.cpp | 349 +++++++++++++++++++++ 3 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 tests/providers/skills_path_resolution.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 524c4a0..7b3201e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -513,6 +513,10 @@ if(FASTMCPP_BUILD_TESTS) target_link_libraries(fastmcpp_provider_skills PRIVATE fastmcpp_core) add_test(NAME fastmcpp_provider_skills COMMAND fastmcpp_provider_skills) + add_executable(fastmcpp_provider_skills_paths tests/providers/skills_path_resolution.cpp) + target_link_libraries(fastmcpp_provider_skills_paths PRIVATE fastmcpp_core) + add_test(NAME fastmcpp_provider_skills_paths COMMAND fastmcpp_provider_skills_paths) + add_executable(fastmcpp_provider_openapi tests/providers/openapi_provider.cpp) target_link_libraries(fastmcpp_provider_openapi PRIVATE fastmcpp_core) add_test(NAME fastmcpp_provider_openapi COMMAND fastmcpp_provider_openapi) diff --git a/src/providers/skills_provider.cpp b/src/providers/skills_provider.cpp index 0c8b900..8db733a 100644 --- a/src/providers/skills_provider.cpp +++ b/src/providers/skills_provider.cpp @@ -241,7 +241,7 @@ std::filesystem::path home_dir() SkillProvider::SkillProvider(std::filesystem::path skill_path, std::string main_file_name, SkillSupportingFiles supporting_files) - : skill_path_(std::filesystem::weakly_canonical(std::filesystem::absolute(std::move(skill_path)))), + : skill_path_(std::filesystem::weakly_canonical(std::filesystem::absolute(skill_path))), skill_name_(skill_path_.filename().string()), main_file_name_(std::move(main_file_name)), supporting_files_(supporting_files) { diff --git a/tests/providers/skills_path_resolution.cpp b/tests/providers/skills_path_resolution.cpp new file mode 100644 index 0000000..931a73a --- /dev/null +++ b/tests/providers/skills_path_resolution.cpp @@ -0,0 +1,349 @@ +#include "fastmcpp/app.hpp" +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/providers/skills_provider.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +using namespace fastmcpp; + +namespace +{ +std::filesystem::path make_temp_dir(const std::string& name) +{ + auto base = std::filesystem::temp_directory_path() / ("fastmcpp_skills_path_" + name); + std::error_code ec; + std::filesystem::remove_all(base, ec); + std::filesystem::create_directories(base); + return base; +} + +void write_text(const std::filesystem::path& path, const std::string& text) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << text; +} + +std::string read_text_data(const resources::ResourceContent& content) +{ + if (auto* text = std::get_if(&content.data)) + return *text; + return {}; +} + +// Create a directory-level indirection (symlink or junction) from link_path +// to target_path. Returns true on success. On Windows, tries symlink first +// (requires developer mode/admin), then falls back to junctions (no admin). +// On POSIX, uses symlinks. +bool create_dir_link(const std::filesystem::path& target, const std::filesystem::path& link_path) +{ + std::error_code ec; + std::filesystem::create_directory_symlink(target, link_path, ec); + if (!ec) + return true; + +#ifdef _WIN32 + // Fall back to NTFS junction (works without admin privileges). + std::string cmd = "cmd /c mklink /J \"" + link_path.string() + "\" \"" + target.string() + "\""; + cmd += " >NUL 2>&1"; + return std::system(cmd.c_str()) == 0; +#else + return false; +#endif +} + +// Remove a directory link (symlink or junction) and all contents. +void remove_dir_link(const std::filesystem::path& link_path) +{ + std::error_code ec; +#ifdef _WIN32 + // Junctions are removed with RemoveDirectoryW, not remove(). + RemoveDirectoryW(link_path.wstring().c_str()); +#endif + std::filesystem::remove(link_path, ec); + // Fall back to remove_all in case a regular directory was left behind. + std::filesystem::remove_all(link_path, ec); +} + +// Check whether creating directory links works on this platform and +// whether weakly_canonical resolves through them (which is the actual +// condition that triggers the bug). +bool links_change_canonical() +{ + auto test_dir = std::filesystem::temp_directory_path() / "fastmcpp_canon_probe_real"; + auto test_link = std::filesystem::temp_directory_path() / "fastmcpp_canon_probe_link"; + std::error_code ec; + std::filesystem::remove_all(test_dir, ec); + remove_dir_link(test_link); + std::filesystem::create_directories(test_dir); + if (!create_dir_link(test_dir, test_link)) + { + std::filesystem::remove_all(test_dir, ec); + return false; + } + + // Write a file through the link and check canonical form. + write_text(test_link / "probe.txt", "x"); + auto via_link = std::filesystem::absolute(test_link / "probe.txt").lexically_normal(); + auto canonical = std::filesystem::weakly_canonical(test_link / "probe.txt"); + bool differs = via_link != canonical; + + remove_dir_link(test_link); + std::filesystem::remove_all(test_dir, ec); + return differs; +} + +void require(bool condition, const std::string& message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << std::endl; + std::abort(); + } +} +} // namespace + +int main() +{ + // --------------------------------------------------------------- + // Test 1: Template resource read through a linked path. + // + // This is the scenario that failed on macOS CI (/tmp -> /private/tmp) + // and Windows CI (8.3 short names). The SkillProvider must resolve + // the skill_path_ to its canonical form so that is_within() works + // when the template provider uses weakly_canonical() on child paths. + // + // Uses symlinks on POSIX, junctions on Windows (no admin needed). + // --------------------------------------------------------------- + if (links_change_canonical()) + { + std::cerr << " [link] Running linked-path resolution tests\n"; + + const auto real_dir = make_temp_dir("link_real"); + const auto link_dir = real_dir.parent_path() / "fastmcpp_skills_path_link"; + remove_dir_link(link_dir); + bool link_ok = create_dir_link(real_dir, link_dir); + require(link_ok, "Failed to create directory link"); + + const auto skill = link_dir / "my-skill"; + write_text(skill / "SKILL.md", "# Linked Skill\nContent here."); + write_text(skill / "data" / "info.txt", "linked-data"); + write_text(skill / "nested" / "deep" / "file.md", "deep-content"); + + // Verify the link is actually an indirection (not a regular directory). + auto child_via_link = std::filesystem::absolute(link_dir / "my-skill" / "data" / "info.txt") + .lexically_normal(); + auto child_canonical = + std::filesystem::weakly_canonical(link_dir / "my-skill" / "data" / "info.txt"); + require(child_via_link != child_canonical, + "Link did not create path indirection: " + child_via_link.string() + + " == " + child_canonical.string()); + + // Construct provider using the link path (not the real path). + auto provider = std::make_shared( + skill, "SKILL.md", providers::SkillSupportingFiles::Template); + FastMCP app("link_test", "1.0.0"); + app.add_provider(provider); + + // Main file should be readable. + auto main_content = app.read_resource("skill://my-skill/SKILL.md"); + require(read_text_data(main_content).find("Linked Skill") != std::string::npos, + "Main file content mismatch through link"); + + // Template-based reads through the linked root must work. + // This is the exact scenario that failed with "Skill path escapes root". + auto info = app.read_resource("skill://my-skill/data/info.txt"); + require(read_text_data(info) == "linked-data", + "Template resource read failed through link"); + + auto deep = app.read_resource("skill://my-skill/nested/deep/file.md"); + require(read_text_data(deep) == "deep-content", + "Nested template resource read failed through link"); + + // Manifest should list all files. + auto manifest_content = app.read_resource("skill://my-skill/_manifest"); + const std::string manifest_text = read_text_data(manifest_content); + require(manifest_text.find("data/info.txt") != std::string::npos, + "Manifest missing data/info.txt"); + require(manifest_text.find("nested/deep/file.md") != std::string::npos, + "Manifest missing nested/deep/file.md"); + + std::cerr << " [link] PASSED\n"; + + // --------------------------------------------------------------- + // Test 2: SkillsDirectoryProvider through a linked root. + // + // Same scenario but with the directory-level provider that + // discovers skills by scanning subdirectories. + // --------------------------------------------------------------- + std::cerr << " [link-dir] Running linked directory provider tests\n"; + + const auto dir_real = make_temp_dir("linkdir_real"); + const auto dir_link = dir_real.parent_path() / "fastmcpp_skills_path_linkdir"; + remove_dir_link(dir_link); + link_ok = create_dir_link(dir_real, dir_link); + require(link_ok, "Failed to create directory link for dir provider"); + + write_text(dir_link / "tool-a" / "SKILL.md", "# Tool A\nFirst tool."); + write_text(dir_link / "tool-a" / "extra.txt", "extra-a"); + + auto dir_provider = std::make_shared( + dir_link, false, "SKILL.md", providers::SkillSupportingFiles::Template); + FastMCP app_dir("link_dir_test", "1.0.0"); + app_dir.add_provider(dir_provider); + + auto tool_main = app_dir.read_resource("skill://tool-a/SKILL.md"); + require(read_text_data(tool_main).find("Tool A") != std::string::npos, + "Dir provider main file read failed through link"); + + auto extra = app_dir.read_resource("skill://tool-a/extra.txt"); + require(read_text_data(extra) == "extra-a", + "Dir provider template resource read failed through link"); + + std::cerr << " [link-dir] PASSED\n"; + + // Cleanup. + remove_dir_link(link_dir); + remove_dir_link(dir_link); + std::error_code ec; + std::filesystem::remove_all(real_dir, ec); + std::filesystem::remove_all(dir_real, ec); + } + else + { + std::cerr << " [link] SKIPPED (cannot create dir links or canonical path unchanged)\n"; + } + + // --------------------------------------------------------------- + // Test 3: Canonical temp path. + // + // Even without an explicit link, temp_directory_path() may differ + // from weakly_canonical(temp_directory_path()) -- e.g. macOS /tmp + // vs /private/tmp, or Windows trailing slash. Use the raw + // (non-canonical) temp path to exercise the provider. + // --------------------------------------------------------------- + { + std::cerr << " [canonical-temp] Running canonical temp path tests\n"; + + const auto raw_tmp = std::filesystem::temp_directory_path(); + const auto root = raw_tmp / "fastmcpp_skills_path_canonical"; + std::error_code ec; + std::filesystem::remove_all(root, ec); + const auto skill = root / "canon-skill"; + write_text(skill / "SKILL.md", "# Canon\nCanonical test."); + write_text(skill / "sub" / "data.txt", "canon-data"); + + auto provider = std::make_shared( + skill, "SKILL.md", providers::SkillSupportingFiles::Template); + FastMCP app("canonical_test", "1.0.0"); + app.add_provider(provider); + + auto main_content = app.read_resource("skill://canon-skill/SKILL.md"); + require(read_text_data(main_content).find("Canon") != std::string::npos, + "Canonical temp: main file content mismatch"); + + auto sub = app.read_resource("skill://canon-skill/sub/data.txt"); + require(read_text_data(sub) == "canon-data", + "Canonical temp: template resource read failed"); + + std::cerr << " [canonical-temp] PASSED\n"; + + std::filesystem::remove_all(root, ec); + } + + // --------------------------------------------------------------- + // Test 4: Path escape attempts must be rejected. + // + // Verify that the is_within security check blocks traversal + // regardless of canonical vs non-canonical path representation. + // --------------------------------------------------------------- + { + std::cerr << " [escape] Running path escape security tests\n"; + + const auto root = make_temp_dir("escape"); + const auto skill = root / "safe-skill"; + write_text(skill / "SKILL.md", "# Safe\nInside root."); + + // Create a file outside the skill directory to verify it can't be read. + write_text(root / "secret.txt", "should-not-be-readable"); + + auto provider = std::make_shared( + skill, "SKILL.md", providers::SkillSupportingFiles::Template); + FastMCP app("escape_test", "1.0.0"); + app.add_provider(provider); + + bool caught_escape = false; + try + { + app.read_resource("skill://safe-skill/../secret.txt"); + } + catch (const std::exception& e) + { + const std::string msg = e.what(); + caught_escape = msg.find("escapes root") != std::string::npos || + msg.find("not found") != std::string::npos; + } + require(caught_escape, "Path escape was not rejected"); + + std::cerr << " [escape] PASSED\n"; + + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + + // --------------------------------------------------------------- + // Test 5: Resources mode through non-canonical path. + // + // In Resources mode, supporting files are enumerated as explicit + // resources (not via template matching). Verify this also works + // when the skill path requires canonicalization. + // --------------------------------------------------------------- + { + std::cerr << " [resources-mode] Running resources mode path tests\n"; + + const auto raw_tmp = std::filesystem::temp_directory_path(); + const auto root = raw_tmp / "fastmcpp_skills_path_resmode"; + std::error_code ec; + std::filesystem::remove_all(root, ec); + const auto skill = root / "res-skill"; + write_text(skill / "SKILL.md", "# Resources\nResources mode."); + write_text(skill / "assets" / "data.json", "{\"key\":\"value\"}"); + + auto provider = std::make_shared( + skill, "SKILL.md", providers::SkillSupportingFiles::Resources); + FastMCP app("resources_mode_test", "1.0.0"); + app.add_provider(provider); + + auto resources = app.list_all_resources(); + bool found_asset = false; + for (const auto& res : resources) + { + if (res.uri == "skill://res-skill/assets/data.json") + { + found_asset = true; + break; + } + } + require(found_asset, "Resources mode: asset not found in resource list"); + + auto asset = app.read_resource("skill://res-skill/assets/data.json"); + require(read_text_data(asset).find("\"key\"") != std::string::npos, + "Resources mode: asset content mismatch"); + + std::cerr << " [resources-mode] PASSED\n"; + + std::filesystem::remove_all(root, ec); + } + + std::cerr << "All skills path resolution tests passed.\n"; + return 0; +}