diff --git a/CMakeLists.txt b/CMakeLists.txt index eb1ca63..7b3201e 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) @@ -177,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) @@ -248,6 +224,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 +252,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) @@ -467,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) @@ -477,6 +480,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 +505,22 @@ 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_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) + # Proxy tests add_executable(fastmcpp_proxy_basic tests/proxy/basic.cpp) target_link_libraries(fastmcpp_proxy_basic PRIVATE fastmcpp_core) @@ -507,6 +531,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 +576,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 +620,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 +652,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..d2235a7 --- /dev/null +++ b/examples/mcp_apps.cpp @@ -0,0 +1,62 @@ +#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..3c4249f --- /dev/null +++ b/examples/openapi_provider.cpp @@ -0,0 +1,38 @@ +#include "fastmcpp/providers/openapi_provider.hpp" + +#include "fastmcpp/app.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..fdceaf7 --- /dev/null +++ b/examples/response_limiting_middleware.cpp @@ -0,0 +1,36 @@ +#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..02e5d11 --- /dev/null +++ b/examples/skills_provider.cpp @@ -0,0 +1,38 @@ +#include "fastmcpp/providers/skills_provider.hpp" + +#include "fastmcpp/app.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..91ef0f2 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 @@ -135,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; @@ -198,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/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..44c4ab1 --- /dev/null +++ b/include/fastmcpp/providers/openapi_provider.hpp @@ -0,0 +1,61 @@ +#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); + 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); + 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; + + 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..72da832 --- /dev/null +++ b/include/fastmcpp/providers/skills_provider.hpp @@ -0,0 +1,151 @@ +#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..e7d222b --- /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..1ca0bb3 --- /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/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 new file mode 100644 index 0000000..2e2abc0 --- /dev/null +++ b/include/fastmcpp/providers/transforms/version_filter.hpp @@ -0,0 +1,44 @@ +#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..464724d --- /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..36c2999 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,55 @@ 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..22a910d --- /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..47bc276 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -5,20 +5,61 @@ #include "fastmcpp/server/server.hpp" #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 +67,18 @@ 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; } @@ -43,17 +95,7 @@ static int tasks_usage(int exit_code = 1) 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"; @@ -63,22 +105,33 @@ static int tasks_usage(int exit_code = 1) 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 +167,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 +197,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 +233,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: + 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)); - case TasksConnection::Kind::WebSocket: - return Client(std::make_unique(conn.url_or_command)); - case TasksConnection::Kind::Stdio: + 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 +401,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 +422,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 +448,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,7 +477,7 @@ 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; } @@ -359,46 +486,42 @@ static int run_tasks_command(int argc, char** argv) auto client = make_client_from_connection(*conn); fastmcpp::Json res = client.call("tasks/cancel", fastmcpp::Json{{"taskId", task_id}}); - dump_json(res); + 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 +534,1133 @@ 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 << "_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"; + 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 +1689,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..fe445d4 100644 --- a/src/client/transports.cpp +++ b/src/client/transports.cpp @@ -1,12 +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 #include @@ -15,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 @@ -171,13 +165,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 +193,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 +323,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 +417,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 +445,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)), @@ -543,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(); @@ -570,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}, @@ -618,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; @@ -693,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; @@ -743,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/mcp/handler.cpp b/src/mcp/handler.cpp index 1639865..988e5e6 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,101 @@ 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 +133,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) @@ -93,13 +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) +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}, @@ -115,8 +234,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 +258,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 +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())); + 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 +985,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 +1050,13 @@ 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 +1150,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 +1253,16 @@ 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 +1342,10 @@ 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 +1357,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 +1449,13 @@ 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()); } }; } @@ -1410,9 +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())); + 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}, @@ -1424,7 +1537,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 +1590,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 +1629,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 +1644,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 +1694,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 +1724,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 +1736,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 +1762,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 +1772,13 @@ 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 +1792,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 +1805,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 +1835,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 +1881,13 @@ 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,19 @@ 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); + return jsonrpc_error(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 +2090,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 +2148,14 @@ 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 +2187,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 +2195,15 @@ 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 +2219,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 +2276,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 +2333,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 +2345,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 +2380,20 @@ 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 +2407,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 +2488,23 @@ 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 +2531,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 +2574,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 +2587,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 +2635,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 +2674,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 +2712,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 +2729,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 +2747,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 +2758,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 +2772,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 +2804,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 +2816,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 +2872,23 @@ 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 +2996,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 +3042,13 @@ 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 +3056,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 +3084,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 +3100,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 +3136,14 @@ 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 +3175,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 +3183,15 @@ 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 +3231,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 +3241,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 +3273,20 @@ 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 +3321,23 @@ 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..248bc81 --- /dev/null +++ b/src/providers/openapi_provider.cpp @@ -0,0 +1,474 @@ +#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) + : 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)) +{ + 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) +{ + 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) +{ + 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..8db733a --- /dev/null +++ b/src/providers/skills_provider.cpp @@ -0,0 +1,586 @@ +#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::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) +{ + 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..6a96993 --- /dev/null +++ b/src/providers/transforms/prompts_as_tools.cpp @@ -0,0 +1,100 @@ +#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..16b2dab --- /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..b2dc941 --- /dev/null +++ b/src/providers/transforms/version_filter.cpp @@ -0,0 +1,179 @@ +#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..9f692f2 --- /dev/null +++ b/src/server/ping_middleware.cpp @@ -0,0 +1,37 @@ +#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..9a58ce8 --- /dev/null +++ b/src/server/response_limiting_middleware.cpp @@ -0,0 +1,71 @@ +#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..4ba465d 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,24 @@ 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..d5317da --- /dev/null +++ b/tests/app/mcp_apps.cpp @@ -0,0 +1,310 @@ +/// @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..b060879 --- /dev/null +++ b/tests/cli/generated_cli_e2e.cpp @@ -0,0 +1,279 @@ +#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..0b66b7e 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,260 @@ 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..158eb0c --- /dev/null +++ b/tests/mcp/test_error_codes.cpp @@ -0,0 +1,88 @@ +/// @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..c6e047f --- /dev/null +++ b/tests/providers/openapi_provider.cpp @@ -0,0 +1,155 @@ +#include "fastmcpp/providers/openapi_provider.hpp" + +#include "fastmcpp/app.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_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; +} diff --git a/tests/providers/skills_provider.cpp b/tests/providers/skills_provider.cpp new file mode 100644 index 0000000..6dba365 --- /dev/null +++ b/tests/providers/skills_provider.cpp @@ -0,0 +1,165 @@ +#include "fastmcpp/providers/skills_provider.hpp" + +#include "fastmcpp/app.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..9b46e74 --- /dev/null +++ b/tests/providers/version_filter.cpp @@ -0,0 +1,133 @@ +#include "fastmcpp/providers/transforms/version_filter.hpp" + +#include "fastmcpp/app.hpp" +#include "fastmcpp/exceptions.hpp" +#include "fastmcpp/providers/local_provider.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..e2f79e0 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 @@ -498,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 new file mode 100644 index 0000000..d16cfab --- /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..b1f4c6f 100644 --- a/tests/server/streamable_http_integration.cpp +++ b/tests/server/streamable_http_integration.cpp @@ -382,6 +382,68 @@ 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 +598,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..f432a58 --- /dev/null +++ b/tests/server/test_response_limiting.cpp @@ -0,0 +1,157 @@ +/// @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..92b9109 100644 --- a/tests/server/test_server_session.cpp +++ b/tests/server/test_server_session.cpp @@ -62,6 +62,25 @@ 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 +414,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..3bf9cc8 --- /dev/null +++ b/tests/tools/test_tool_sequential.cpp @@ -0,0 +1,84 @@ +/// @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..c377ad4 --- /dev/null +++ b/tests/tools/test_tool_transform_enabled.cpp @@ -0,0 +1,96 @@ +/// @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/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; +} 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; -}