diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f63c8f..e60f77b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,11 @@ target_include_directories(fastmcpp_core PUBLIC # Version header is public target_compile_definitions(fastmcpp_core PUBLIC FASTMCPP_VERSION_MAJOR=${PROJECT_VERSION_MAJOR} FASTMCPP_VERSION_MINOR=${PROJECT_VERSION_MINOR} FASTMCPP_VERSION_PATCH=${PROJECT_VERSION_PATCH}) +# MSVC: avoid parallel compilation PDB contention (C1041) in large targets +if(MSVC) + target_compile_options(fastmcpp_core PRIVATE /FS) +endif() + # nlohmann_json dependency - use existing target if available (e.g., from vcpkg) if(NOT TARGET nlohmann_json::nlohmann_json) include(FetchContent) diff --git a/include/fastmcpp/client/types.hpp b/include/fastmcpp/client/types.hpp index 6a9d9b3..56bddf7 100644 --- a/include/fastmcpp/client/types.hpp +++ b/include/fastmcpp/client/types.hpp @@ -60,6 +60,7 @@ struct ToolInfo std::optional description; fastmcpp::Json inputSchema; ///< JSON Schema for tool input std::optional outputSchema; ///< JSON Schema for structured output + std::optional execution; ///< Execution config (SEP-1686) std::optional> icons; ///< Icons for UI display }; @@ -323,6 +324,8 @@ inline void to_json(fastmcpp::Json& j, const ToolInfo& t) j["description"] = *t.description; if (t.outputSchema) j["outputSchema"] = *t.outputSchema; + if (t.execution) + j["execution"] = *t.execution; if (t.icons) j["icons"] = *t.icons; } @@ -337,6 +340,8 @@ inline void from_json(const fastmcpp::Json& j, ToolInfo& t) t.inputSchema = j.value("inputSchema", fastmcpp::Json::object()); if (j.contains("outputSchema")) t.outputSchema = j["outputSchema"]; + if (j.contains("execution")) + t.execution = j["execution"]; if (j.contains("icons")) t.icons = j["icons"].get>(); } diff --git a/include/fastmcpp/prompts/prompt.hpp b/include/fastmcpp/prompts/prompt.hpp index f793a1e..742fba3 100644 --- a/include/fastmcpp/prompts/prompt.hpp +++ b/include/fastmcpp/prompts/prompt.hpp @@ -31,7 +31,8 @@ struct Prompt std::string name; std::optional description; std::vector arguments; - std::function(const Json&)> generator; // Message generator + std::function(const Json&)> generator; // Message generator + fastmcpp::TaskSupport task_support{fastmcpp::TaskSupport::Forbidden}; // SEP-1686 task mode // Legacy constructor for backwards compatibility Prompt() = default; diff --git a/include/fastmcpp/resources/resource.hpp b/include/fastmcpp/resources/resource.hpp index affa2c2..d2c34e4 100644 --- a/include/fastmcpp/resources/resource.hpp +++ b/include/fastmcpp/resources/resource.hpp @@ -26,6 +26,7 @@ struct Resource std::optional description; // Optional description std::optional mime_type; // MIME type hint std::function provider; // Content provider function + fastmcpp::TaskSupport task_support{fastmcpp::TaskSupport::Forbidden}; // SEP-1686 task mode // Legacy fields (for backwards compatibility) fastmcpp::Id id; diff --git a/include/fastmcpp/tools/tool.hpp b/include/fastmcpp/tools/tool.hpp index f16a2e1..2b8c9b7 100644 --- a/include/fastmcpp/tools/tool.hpp +++ b/include/fastmcpp/tools/tool.hpp @@ -18,10 +18,11 @@ class Tool // Original constructor (backward compatible) Tool(std::string name, fastmcpp::Json input_schema, fastmcpp::Json output_schema, Fn fn, - std::vector exclude_args = {}) + std::vector exclude_args = {}, + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden) : 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)) + exclude_args_(std::move(exclude_args)), task_support_(task_support) { } @@ -29,10 +30,12 @@ class Tool Tool(std::string name, fastmcpp::Json input_schema, fastmcpp::Json output_schema, Fn fn, std::optional title, std::optional description, std::optional> icons, - std::vector exclude_args = {}) + std::vector exclude_args = {}, + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden) : 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)) + icons_(std::move(icons)), fn_(std::move(fn)), exclude_args_(std::move(exclude_args)), + task_support_(task_support) { } @@ -67,6 +70,11 @@ class Tool return fn_(input); } + fastmcpp::TaskSupport task_support() const + { + return task_support_; + } + // Setters for optional fields (builder pattern) Tool& set_title(std::string title) { @@ -83,6 +91,11 @@ class Tool icons_ = std::move(icons); return *this; } + Tool& set_task_support(fastmcpp::TaskSupport support) + { + task_support_ = support; + return *this; + } private: fastmcpp::Json prune_schema(const fastmcpp::Json& schema) const @@ -126,6 +139,7 @@ class Tool std::optional> icons_; Fn fn_; std::vector exclude_args_; + fastmcpp::TaskSupport task_support_{fastmcpp::TaskSupport::Forbidden}; }; } // namespace fastmcpp::tools diff --git a/include/fastmcpp/types.hpp b/include/fastmcpp/types.hpp index d89bc04..0bef593 100644 --- a/include/fastmcpp/types.hpp +++ b/include/fastmcpp/types.hpp @@ -9,6 +9,38 @@ namespace fastmcpp using Json = nlohmann::json; +/// Background task execution support mode (SEP-1686). +/// Mirrors fastmcp.server.tasks.TaskConfig.mode / MCP ToolExecution.taskSupport. +enum class TaskSupport +{ + Forbidden, ///< No task augmentation allowed + Optional, ///< Task augmentation supported but not required + Required ///< Task augmentation required +}; + +inline std::string to_string(TaskSupport support) +{ + switch (support) + { + case TaskSupport::Forbidden: + return "forbidden"; + case TaskSupport::Optional: + return "optional"; + case TaskSupport::Required: + return "required"; + } + return "forbidden"; +} + +inline TaskSupport task_support_from_string(const std::string& s) +{ + if (s == "optional") + return TaskSupport::Optional; + if (s == "required") + return TaskSupport::Required; + return TaskSupport::Forbidden; +} + struct Id { std::string value; diff --git a/src/app.cpp b/src/app.cpp index 3805293..f08770e 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -166,7 +166,11 @@ std::vector FastMCP::list_all_tools_info() const info.inputSchema = tool.input_schema(); info.title = tool.title(); info.description = tool.description(); - info.outputSchema = tool.output_schema(); + 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.icons = tool.icons(); result.push_back(info); } diff --git a/src/mcp/handler.cpp b/src/mcp/handler.cpp index 02e54b0..3b7a1eb 100644 --- a/src/mcp/handler.cpp +++ b/src/mcp/handler.cpp @@ -23,11 +23,50 @@ static fastmcpp::Json jsonrpc_error(const fastmcpp::Json& id, int code, const st {"error", fastmcpp::Json{{"code", code}, {"message", message}}}}; } +static bool schema_is_object(const fastmcpp::Json& schema) +{ + if (!schema.is_object()) + return false; + + auto it = schema.find("type"); + if (it != schema.end() && it->is_string() && it->get() == "object") + return true; + + if (schema.contains("properties")) + return true; + + // Self-referencing types often use a top-level $ref into $defs. + if (schema.contains("$ref") && schema.contains("$defs")) + return true; + + return false; +} + +static fastmcpp::Json normalize_output_schema_for_mcp(const fastmcpp::Json& schema) +{ + if (schema.is_null()) + return schema; + + // Python fastmcp requires object-shaped output schemas (MCP structuredContent is a dict). + // For scalar/array outputs, wrap into {"result": ...} and annotate for clients. + if (schema_is_object(schema)) + return schema; + + return fastmcpp::Json{ + {"type", "object"}, + {"properties", fastmcpp::Json{{"result", schema}}}, + {"required", fastmcpp::Json::array({"result"})}, + {"x-fastmcp-wrap-result", true}, + }; +} + 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 std::optional>& icons = std::nullopt, + const fastmcpp::Json& output_schema = fastmcpp::Json(), + fastmcpp::TaskSupport task_support = fastmcpp::TaskSupport::Forbidden) { fastmcpp::Json entry = { {"name", name}, @@ -41,6 +80,10 @@ make_tool_entry(const std::string& name, const std::string& description, entry["inputSchema"] = schema; else 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)}}; // Add icons if present if (icons && !icons->empty()) { @@ -148,15 +191,31 @@ class TaskRegistry std::atomic next_id_{0}; }; -// Helper: convert FastMCP::invoke_tool() result JSON into the MCP -// CallToolResult payload shape: { "content": [...] } where content is -// a list of ContentBlock objects. -fastmcpp::Json build_fastmcp_tool_result(const fastmcpp::Json& result) +// Helper: convert a tool invocation JSON result into an MCP CallToolResult payload. +// For tools that declare an outputSchema, include structuredContent for parity with Python fastmcp. +fastmcpp::Json build_fastmcp_tool_result(const fastmcpp::Json& result, + bool include_structured_content = false) { - fastmcpp::Json content = fastmcpp::Json::array(); + // If the tool already returned a CallToolResult-like object, preserve it (including isError, + // structuredContent, and _meta). if (result.is_object() && result.contains("content")) - content = result.at("content"); - else if (result.is_array()) + { + fastmcpp::Json payload = result; + if (!payload["content"].is_array()) + { + if (payload["content"].is_object()) + payload["content"] = fastmcpp::Json::array({payload["content"]}); + else + payload["content"] = fastmcpp::Json::array(); + } + if (payload.contains("structuredContent") && !payload["structuredContent"].is_object()) + payload["structuredContent"] = + fastmcpp::Json{{"result", std::move(payload["structuredContent"])}}; + return payload; + } + + fastmcpp::Json content = fastmcpp::Json::array(); + if (result.is_array()) content = result; else if (result.is_string()) content = fastmcpp::Json::array( @@ -165,7 +224,15 @@ fastmcpp::Json build_fastmcp_tool_result(const fastmcpp::Json& result) content = fastmcpp::Json::array({fastmcpp::Json{{"type", "text"}, {"text", result.dump()}}}); - return fastmcpp::Json{{"content", content}}; + fastmcpp::Json payload = fastmcpp::Json{{"content", content}}; + if (include_structured_content) + { + if (result.is_object()) + payload["structuredContent"] = result; + else + payload["structuredContent"] = fastmcpp::Json{{"result", result}}; + } + return payload; } // Extract SEP-1686 task TTL from request params._meta if present. @@ -183,6 +250,61 @@ inline bool extract_task_ttl(const fastmcpp::Json& params, int& ttl_ms_out) ttl_ms_out = task_meta["ttl"].get(); return true; } + +inline fastmcpp::Json tasks_capabilities() +{ + return fastmcpp::Json{ + {"list", fastmcpp::Json::object()}, + {"cancel", fastmcpp::Json::object()}, + {"requests", + fastmcpp::Json{ + {"tools", fastmcpp::Json{{"call", fastmcpp::Json::object()}}}, + {"prompts", fastmcpp::Json{{"get", fastmcpp::Json::object()}}}, + {"resources", fastmcpp::Json{{"read", fastmcpp::Json::object()}}}, + }}, + }; +} + +inline bool app_supports_tasks(const fastmcpp::FastMCP& app) +{ + for (const auto& [name, tool] : app.list_all_tools()) + if (tool && tool->task_support() != fastmcpp::TaskSupport::Forbidden) + return true; + for (const auto& res : app.list_all_resources()) + if (res.task_support != fastmcpp::TaskSupport::Forbidden) + return true; + for (const auto& [name, prompt] : app.list_all_prompts()) + if (prompt && prompt->task_support != fastmcpp::TaskSupport::Forbidden) + return true; + return false; +} + +inline std::optional find_tool_task_support(const fastmcpp::FastMCP& app, + const std::string& name) +{ + for (const auto& [tool_name, tool] : app.list_all_tools()) + if (tool_name == name && tool) + return tool->task_support(); + return std::nullopt; +} + +inline std::optional find_prompt_task_support(const fastmcpp::FastMCP& app, + const std::string& name) +{ + for (const auto& [prompt_name, prompt] : app.list_all_prompts()) + if (prompt_name == name && prompt) + return prompt->task_support; + return std::nullopt; +} + +inline std::optional find_resource_task_support(const fastmcpp::FastMCP& app, + const std::string& uri) +{ + for (const auto& res : app.list_all_resources()) + if (res.uri == uri) + return res.task_support; + return std::nullopt; +} } // namespace std::function @@ -254,8 +376,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())); + tools_array.push_back(make_tool_entry(name, desc, schema, tool.title(), + tool.icons(), tool.output_schema(), + tool.task_support())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, @@ -271,30 +394,14 @@ make_mcp_handler(const std::string& server_name, const std::string& version, return jsonrpc_error(id, -32602, "Missing tool name"); try { + const auto& tool = tools.get(name); + bool has_output_schema = !tool.output_schema().is_null(); + auto result = tools.invoke(name, args); - // If handler returns a content array or object with content, pass through - fastmcpp::Json content = fastmcpp::Json::array(); - if (result.is_object() && result.contains("content")) - { - content = result.at("content"); - } - else if (result.is_array()) - { - content = result; - } - else if (result.is_string()) - { - content = fastmcpp::Json::array({fastmcpp::Json{ - {"type", "text"}, {"text", result.get()}}}); - } - else - { - content = fastmcpp::Json::array( - {fastmcpp::Json{{"type", "text"}, {"text", result.dump()}}}); - } - return fastmcpp::Json{{"jsonrpc", "2.0"}, - {"id", id}, - {"result", fastmcpp::Json{{"content", content}}}}; + fastmcpp::Json result_payload = + build_fastmcp_tool_result(result, has_output_schema); + return fastmcpp::Json{ + {"jsonrpc", "2.0"}, {"id", id}, {"result", result_payload}}; } catch (const std::exception& e) { @@ -445,28 +552,9 @@ std::function make_mcp_handler( try { auto result = server.handle(name, args); - fastmcpp::Json content = fastmcpp::Json::array(); - if (result.is_object() && result.contains("content")) - { - content = result.at("content"); - } - else if (result.is_array()) - { - content = result; - } - else if (result.is_string()) - { - content = fastmcpp::Json::array({fastmcpp::Json{ - {"type", "text"}, {"text", result.get()}}}); - } - else - { - content = fastmcpp::Json::array( - {fastmcpp::Json{{"type", "text"}, {"text", result.dump()}}}); - } - return fastmcpp::Json{{"jsonrpc", "2.0"}, - {"id", id}, - {"result", fastmcpp::Json{{"content", content}}}}; + fastmcpp::Json result_payload = build_fastmcp_tool_result(result); + return fastmcpp::Json{ + {"jsonrpc", "2.0"}, {"id", id}, {"result", result_payload}}; } catch (const std::exception& e) { @@ -667,22 +755,15 @@ make_mcp_handler(const std::string& server_name, const std::string& version, return jsonrpc_error(id, -32602, "Missing tool name"); try { + const auto& tool = tools.get(name); + bool has_output_schema = !tool.output_schema().is_null(); + // Use tools.invoke() directly - this is why we capture tools auto result = tools.invoke(name, args); - fastmcpp::Json content = fastmcpp::Json::array(); - if (result.is_object() && result.contains("content")) - content = result.at("content"); - else if (result.is_array()) - content = result; - else if (result.is_string()) - content = fastmcpp::Json::array({fastmcpp::Json{ - {"type", "text"}, {"text", result.get()}}}); - else - content = fastmcpp::Json::array( - {fastmcpp::Json{{"type", "text"}, {"text", result.dump()}}}); - return fastmcpp::Json{{"jsonrpc", "2.0"}, - {"id", id}, - {"result", fastmcpp::Json{{"content", content}}}}; + fastmcpp::Json result_payload = + build_fastmcp_tool_result(result, has_output_schema); + return fastmcpp::Json{ + {"jsonrpc", "2.0"}, {"id", id}, {"result", result_payload}}; } catch (const std::exception& e) { @@ -795,27 +876,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()}}; - 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())); } return fastmcpp::Json{{"jsonrpc", "2.0"}, {"id", id}, @@ -830,21 +894,14 @@ make_mcp_handler(const std::string& server_name, const std::string& version, return jsonrpc_error(id, -32602, "Missing tool name"); try { + const auto& tool = tools.get(name); + bool has_output_schema = !tool.output_schema().is_null(); + auto result = tools.invoke(name, args); - fastmcpp::Json content = fastmcpp::Json::array(); - if (result.is_object() && result.contains("content")) - content = result.at("content"); - else if (result.is_array()) - content = result; - else if (result.is_string()) - content = fastmcpp::Json::array({fastmcpp::Json{ - {"type", "text"}, {"text", result.get()}}}); - else - content = fastmcpp::Json::array( - {fastmcpp::Json{{"type", "text"}, {"text", result.dump()}}}); - return fastmcpp::Json{{"jsonrpc", "2.0"}, - {"id", id}, - {"result", fastmcpp::Json{{"content", content}}}}; + fastmcpp::Json result_payload = + build_fastmcp_tool_result(result, has_output_schema); + return fastmcpp::Json{ + {"jsonrpc", "2.0"}, {"id", id}, {"result", result_payload}}; } catch (const std::exception& e) { @@ -1049,6 +1106,8 @@ std::function make_mcp_handler(const Fast // Advertise capabilities fastmcpp::Json capabilities = {{"tools", fastmcpp::Json::object()}}; + if (app_supports_tasks(app)) + capabilities["tasks"] = tasks_capabilities(); if (!app.list_all_resources().empty() || !app.list_all_templates().empty()) capabilities["resources"] = fastmcpp::Json::object(); if (!app.list_all_prompts().empty()) @@ -1079,6 +1138,11 @@ std::function make_mcp_handler(const Fast tool_json["title"] = *tool_info.title; if (tool_info.description) tool_json["description"] = *tool_info.description; + if (tool_info.outputSchema && !tool_info.outputSchema->is_null()) + tool_json["outputSchema"] = + normalize_output_schema_for_mcp(*tool_info.outputSchema); + if (tool_info.execution) + tool_json["execution"] = *tool_info.execution; if (tool_info.icons && !tool_info.icons->empty()) { fastmcpp::Json icons_json = fastmcpp::Json::array(); @@ -1108,6 +1172,16 @@ std::function make_mcp_handler(const Fast return jsonrpc_error(id, -32602, "Missing tool name"); try { + bool has_output_schema = false; + for (const auto& tool_info : app.list_all_tools_info()) + { + if (tool_info.name != name) + continue; + has_output_schema = + tool_info.outputSchema && !tool_info.outputSchema->is_null(); + break; + } + // Detect SEP-1686 task metadata via params._meta int ttl_ms = 60000; bool has_task_meta = false; @@ -1124,13 +1198,25 @@ std::function make_mcp_handler(const Fast } } + auto support = find_tool_task_support(app, name); + if (support) + { + if (has_task_meta && *support == fastmcpp::TaskSupport::Forbidden) + return jsonrpc_error(id, -32601, + "Task execution forbidden for tool: " + name); + if (!has_task_meta && *support == fastmcpp::TaskSupport::Required) + return jsonrpc_error(id, -32601, + "Task execution required for tool: " + name); + } + if (has_task_meta) { // Minimal server-side tasks support: execute synchronously but expose // result via tasks/get and tasks/result. The initial stub reports a // taskId and completed status so clients can poll if desired. auto invoke_result = app.invoke_tool(name, args); - fastmcpp::Json result_payload = build_fastmcp_tool_result(invoke_result); + fastmcpp::Json result_payload = + build_fastmcp_tool_result(invoke_result, has_output_schema); std::string task_id = tasks->add_completed_task( "tool", name, std::move(result_payload), ttl_ms); @@ -1155,7 +1241,8 @@ std::function make_mcp_handler(const Fast // Synchronous execution (no task metadata) auto invoke_result = app.invoke_tool(name, args); - fastmcpp::Json result_payload = build_fastmcp_tool_result(invoke_result); + fastmcpp::Json result_payload = + build_fastmcp_tool_result(invoke_result, has_output_schema); return fastmcpp::Json{ {"jsonrpc", "2.0"}, {"id", id}, @@ -1318,6 +1405,17 @@ std::function make_mcp_handler(const Fast int ttl_ms = 60000; bool as_task = extract_task_ttl(params, ttl_ms); + auto support = find_resource_task_support(app, uri); + if (support) + { + if (as_task && *support == fastmcpp::TaskSupport::Forbidden) + return jsonrpc_error(id, -32601, + "Task execution forbidden for resource: " + uri); + if (!as_task && *support == fastmcpp::TaskSupport::Required) + return jsonrpc_error(id, -32601, + "Task execution required for resource: " + uri); + } + auto content = app.read_resource(uri, params); fastmcpp::Json content_json = {{"uri", content.uri}}; if (content.mime_type) @@ -1425,6 +1523,17 @@ std::function make_mcp_handler(const Fast int ttl_ms = 60000; bool as_task = extract_task_ttl(params, ttl_ms); + auto support = find_prompt_task_support(app, name); + if (support) + { + if (as_task && *support == fastmcpp::TaskSupport::Forbidden) + return jsonrpc_error(id, -32601, + "Task execution forbidden for prompt: " + name); + if (!as_task && *support == fastmcpp::TaskSupport::Required) + return jsonrpc_error(id, -32601, + "Task execution required for prompt: " + name); + } + fastmcpp::Json args = params.value("arguments", fastmcpp::Json::object()); auto messages = app.get_prompt(name, args); @@ -1530,6 +1639,8 @@ std::function make_mcp_handler(const Prox tool_json["title"] = *tool.title; if (tool.outputSchema) tool_json["outputSchema"] = *tool.outputSchema; + if (tool.execution) + tool_json["execution"] = *tool.execution; if (tool.icons) { fastmcpp::Json icons_array = fastmcpp::Json::array(); @@ -1890,6 +2001,8 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces {"tools", fastmcpp::Json::object()}, {"sampling", fastmcpp::Json::object()} // We support sampling }; + if (app_supports_tasks(app)) + capabilities["tasks"] = tasks_capabilities(); if (!app.list_all_resources().empty() || !app.list_all_templates().empty()) capabilities["resources"] = fastmcpp::Json::object(); if (!app.list_all_prompts().empty()) @@ -1920,6 +2033,11 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces tool_json["title"] = *tool_info.title; if (tool_info.description) tool_json["description"] = *tool_info.description; + if (tool_info.outputSchema && !tool_info.outputSchema->is_null()) + tool_json["outputSchema"] = + normalize_output_schema_for_mcp(*tool_info.outputSchema); + if (tool_info.execution) + tool_json["execution"] = *tool_info.execution; if (tool_info.icons && !tool_info.icons->empty()) { fastmcpp::Json icons_json = fastmcpp::Json::array(); @@ -1948,6 +2066,16 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces if (name.empty()) return jsonrpc_error(id, -32602, "Missing tool name"); + bool has_output_schema = false; + for (const auto& tool_info : app.list_all_tools_info()) + { + if (tool_info.name != name) + continue; + has_output_schema = + tool_info.outputSchema && !tool_info.outputSchema->is_null(); + break; + } + // Inject _meta with session_id and sampling callback into args // This allows tools to access sampling via Context if (!session_id.empty()) @@ -1966,20 +2094,10 @@ make_mcp_handler_with_sampling(const FastMCP& app, SessionAccessor session_acces try { auto result = app.invoke_tool(name, args); - fastmcpp::Json content = fastmcpp::Json::array(); - if (result.is_object() && result.contains("content")) - content = result.at("content"); - else if (result.is_array()) - content = result; - else if (result.is_string()) - content = fastmcpp::Json::array({fastmcpp::Json{ - {"type", "text"}, {"text", result.get()}}}); - else - content = fastmcpp::Json::array( - {fastmcpp::Json{{"type", "text"}, {"text", result.dump()}}}); - return fastmcpp::Json{{"jsonrpc", "2.0"}, - {"id", id}, - {"result", fastmcpp::Json{{"content", content}}}}; + fastmcpp::Json result_payload = + build_fastmcp_tool_result(result, has_output_schema); + return fastmcpp::Json{ + {"jsonrpc", "2.0"}, {"id", id}, {"result", result_payload}}; } catch (const std::exception& e) { diff --git a/src/proxy.cpp b/src/proxy.cpp index 01f1f5e..bfd1657 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -25,6 +25,8 @@ client::ToolInfo ProxyApp::tool_to_info(const tools::Tool& tool) info.inputSchema = tool.input_schema(); if (!tool.output_schema().is_null()) info.outputSchema = tool.output_schema(); + if (tool.task_support() != TaskSupport::Forbidden) + info.execution = fastmcpp::Json{{"taskSupport", to_string(tool.task_support())}}; info.title = tool.title(); info.icons = tool.icons(); return info; diff --git a/tests/client/sse_session_client.cpp b/tests/client/sse_session_client.cpp index 9c2de92..2079e9d 100644 --- a/tests/client/sse_session_client.cpp +++ b/tests/client/sse_session_client.cpp @@ -27,7 +27,7 @@ int main() srv->route("sum", [](const Json& j) { return j.at("a").get() + j.at("b").get(); }); // Start HTTP server - const int port = 18301; + const int port = 18321; const std::string host = "127.0.0.1"; server::HttpServerWrapper http_server(srv, host, port); diff --git a/tests/client/tasks.cpp b/tests/client/tasks.cpp index 311b38e..55cd755 100644 --- a/tests/client/tasks.cpp +++ b/tests/client/tasks.cpp @@ -71,6 +71,7 @@ void test_call_tool_task_with_server_tasks() double b = in.at("b").get(); return Json(a + b); }}; + add_tool.set_task_support(TaskSupport::Optional); app.tools().register_tool(add_tool); @@ -97,6 +98,11 @@ void test_call_tool_task_with_server_tasks() assert(text != nullptr); // Accept minor formatting differences around the numeric value assert(text->text.find("5") != std::string::npos); + assert(result.structuredContent.has_value()); + assert(result.structuredContent->is_object()); + assert(result.structuredContent->contains("result")); + assert((*result.structuredContent)["result"].is_number()); + assert((*result.structuredContent)["result"].get() == 5.0); std::cout << " [PASS] Server-side tasks path works with InProcessMcpTransport\n"; } @@ -115,6 +121,7 @@ void test_prompt_and_resource_tasks_with_server_tasks() res.id = Id{"mem://hello"}; res.kind = resources::Kind::Text; res.metadata = Json::object(); + res.task_support = TaskSupport::Optional; res.provider = [](const Json&) -> resources::ResourceContent { resources::ResourceContent rc; @@ -127,6 +134,7 @@ void test_prompt_and_resource_tasks_with_server_tasks() // Register a simple prompt prompts::Prompt greeting("Hello {{name}}!"); + greeting.task_support = TaskSupport::Optional; app.prompts().add("greeting", greeting); auto handler = mcp::make_mcp_handler(app); @@ -155,6 +163,207 @@ void test_prompt_and_resource_tasks_with_server_tasks() std::cout << " [PASS] Prompt and resource tasks work with FastMCP handler\n"; } +void test_task_support_execution_and_capabilities() +{ + std::cout << "Test 5: TaskSupport enforcement + execution/capabilities...\n"; + + // App with a mix of task support modes + FastMCP app("task-support-app", "1.0.0"); + + Json input_schema = {{"type", "object"}, + {"properties", Json::object({{"x", Json{{"type", "number"}}}})}}; + + tools::Tool required_tool{"required_tool", input_schema, Json{{"type", "number"}}, + [](const Json& in) { return Json(in.at("x").get() + 1.0); }}; + required_tool.set_task_support(TaskSupport::Required); + + tools::Tool optional_tool{"optional_tool", input_schema, Json{{"type", "number"}}, + [](const Json& in) { return Json(in.at("x").get() + 2.0); }}; + optional_tool.set_task_support(TaskSupport::Optional); + + tools::Tool forbidden_tool{"forbidden_tool", input_schema, Json{{"type", "number"}}, + [](const Json& in) { return Json(in.at("x").get() + 3.0); }}; + forbidden_tool.set_task_support(TaskSupport::Forbidden); + + app.tools().register_tool(required_tool); + app.tools().register_tool(optional_tool); + app.tools().register_tool(forbidden_tool); + + // Prompt support + prompts::Prompt required_prompt("hello"); + required_prompt.task_support = TaskSupport::Required; + app.prompts().add("required_prompt", required_prompt); + + prompts::Prompt forbidden_prompt("hello"); + forbidden_prompt.task_support = TaskSupport::Forbidden; + app.prompts().add("forbidden_prompt", forbidden_prompt); + + // Resource support + resources::Resource required_res; + required_res.uri = "mem://required"; + required_res.name = "mem://required"; + required_res.mime_type = std::string("text/plain"); + required_res.id = Id{"mem://required"}; + required_res.kind = resources::Kind::Text; + required_res.metadata = Json::object(); + required_res.task_support = TaskSupport::Required; + required_res.provider = [](const Json&) -> resources::ResourceContent + { + resources::ResourceContent rc; + rc.uri = "mem://required"; + rc.mime_type = std::string("text/plain"); + rc.data = std::string("required resource"); + return rc; + }; + app.resources().register_resource(required_res); + + resources::Resource forbidden_res = required_res; + forbidden_res.uri = "mem://forbidden"; + forbidden_res.name = "mem://forbidden"; + forbidden_res.id = Id{"mem://forbidden"}; + forbidden_res.task_support = TaskSupport::Forbidden; + forbidden_res.provider = [](const Json&) -> resources::ResourceContent + { + resources::ResourceContent rc; + rc.uri = "mem://forbidden"; + rc.mime_type = std::string("text/plain"); + rc.data = std::string("forbidden resource"); + return rc; + }; + app.resources().register_resource(forbidden_res); + + auto handler = mcp::make_mcp_handler(app); + client::Client c(std::make_unique(std::move(handler))); + + // Capabilities should advertise tasks when any component supports it + Json init = + c.call("initialize", Json{{"protocolVersion", "2024-11-05"}, + {"capabilities", Json::object()}, + {"clientInfo", {{"name", "fastmcpp"}, {"version", "test"}}}}); + assert(init.contains("capabilities")); + assert(init["capabilities"].contains("tasks")); + + // tools/list should include execution.taskSupport for optional/required tools only + auto tools_list = c.list_tools_mcp(); + bool saw_required = false; + bool saw_optional = false; + bool saw_forbidden = false; + for (const auto& t : tools_list.tools) + { + if (t.name == "required_tool") + { + saw_required = true; + assert(t.execution.has_value()); + assert(t.execution->contains("taskSupport")); + assert((*t.execution)["taskSupport"] == "required"); + } + else if (t.name == "optional_tool") + { + saw_optional = true; + assert(t.execution.has_value()); + assert(t.execution->contains("taskSupport")); + assert((*t.execution)["taskSupport"] == "optional"); + } + else if (t.name == "forbidden_tool") + { + saw_forbidden = true; + assert(!t.execution.has_value()); + } + } + assert(saw_required && saw_optional && saw_forbidden); + + // Tool enforcement: + // - required tool should fail without task meta + { + bool threw = false; + try + { + c.call_tool_mcp("required_tool", Json{{"x", 1}}, client::CallToolOptions{}); + } + catch (const fastmcpp::Error& e) + { + threw = true; + assert(std::string(e.what()).find("required") != std::string::npos); + } + assert(threw); + } + + // - forbidden tool should fail with task meta + { + bool threw = false; + try + { + c.call_tool_task("forbidden_tool", Json{{"x", 1}}, 60000); + } + catch (const fastmcpp::Error& e) + { + threw = true; + assert(std::string(e.what()).find("forbidden") != std::string::npos); + } + assert(threw); + } + + // Prompt enforcement: + // - required prompt should fail without task meta + { + bool threw = false; + try + { + c.get_prompt("required_prompt"); + } + catch (const fastmcpp::Error&) + { + threw = true; + } + assert(threw); + } + + // - forbidden prompt should fail with task meta + { + bool threw = false; + try + { + c.get_prompt_task("forbidden_prompt"); + } + catch (const fastmcpp::Error&) + { + threw = true; + } + assert(threw); + } + + // Resource enforcement: + // - required resource should fail without task meta + { + bool threw = false; + try + { + c.read_resource("mem://required"); + } + catch (const fastmcpp::Error&) + { + threw = true; + } + assert(threw); + } + + // - forbidden resource should fail with task meta + { + bool threw = false; + try + { + c.read_resource_task("mem://forbidden", 60000); + } + catch (const fastmcpp::Error&) + { + threw = true; + } + assert(threw); + } + + std::cout << " [PASS] TaskSupport enforcement and tool execution metadata validated\n"; +} + int main() { std::cout << "Running Client Task API tests (C++ client-side)...\n\n"; @@ -164,7 +373,8 @@ int main() test_call_tool_task_wait(); test_call_tool_task_with_server_tasks(); test_prompt_and_resource_tasks_with_server_tasks(); - std::cout << "\n[OK] Client Task API tests passed! (4 tests)\n"; + test_task_support_execution_and_capabilities(); + std::cout << "\n[OK] Client Task API tests passed! (5 tests)\n"; return 0; } catch (const std::exception& e) diff --git a/tests/mcp/handler.cpp b/tests/mcp/handler.cpp index 684f1cd..f9113af 100644 --- a/tests/mcp/handler.cpp +++ b/tests/mcp/handler.cpp @@ -38,6 +38,11 @@ int main() {"params", Json{{"name", "add"}, {"arguments", Json{{"a", 2}, {"b", 3}}}}}}; auto call_resp = handler(call); assert(call_resp["result"]["content"].size() == 1); + assert(call_resp["result"].contains("structuredContent")); + assert(call_resp["result"]["structuredContent"].is_object()); + assert(call_resp["result"]["structuredContent"].contains("result")); + assert(call_resp["result"]["structuredContent"]["result"].is_number()); + assert(call_resp["result"]["structuredContent"]["result"].get() == 5.0); auto item = call_resp["result"]["content"][0]; assert(item["type"] == "text"); assert(item["text"].get().find("5") != std::string::npos); diff --git a/tests/server/auth_cors_security.cpp b/tests/server/auth_cors_security.cpp index f5428b7..6cc5b80 100644 --- a/tests/server/auth_cors_security.cpp +++ b/tests/server/auth_cors_security.cpp @@ -23,7 +23,7 @@ int main() auto srv = std::make_shared(); srv->route("test", [](const Json&) { return Json{{"result", "ok"}}; }); - HttpServerWrapper http_server(srv, "127.0.0.1", 18399); // No auth token + HttpServerWrapper http_server(srv, "127.0.0.1", 18599); // No auth token if (!http_server.start()) { std::cerr << "Failed to start HTTP server\n"; @@ -32,7 +32,7 @@ int main() std::this_thread::sleep_for(std::chrono::milliseconds(100)); - httplib::Client client("127.0.0.1", 18399); + httplib::Client client("127.0.0.1", 18599); Json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "test"}}; auto res = client.Post("/test", request.dump(), "application/json"); @@ -54,7 +54,7 @@ int main() auto srv = std::make_shared(); srv->route("test", [](const Json&) { return Json{{"result", "ok"}}; }); - HttpServerWrapper http_server(srv, "127.0.0.1", 18400, "secret_token_123"); + HttpServerWrapper http_server(srv, "127.0.0.1", 18600, "secret_token_123"); if (!http_server.start()) { std::cerr << "Failed to start HTTP server with auth\n"; @@ -63,7 +63,7 @@ int main() std::this_thread::sleep_for(std::chrono::milliseconds(100)); - httplib::Client client("127.0.0.1", 18400); + httplib::Client client("127.0.0.1", 18600); Json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "test"}}; auto res = client.Post("/test", request.dump(), "application/json"); @@ -86,7 +86,7 @@ int main() auto srv = std::make_shared(); srv->route("test", [](const Json&) { return Json{{"result", "ok"}}; }); - HttpServerWrapper http_server(srv, "127.0.0.1", 18401, "secret_token_123"); + HttpServerWrapper http_server(srv, "127.0.0.1", 18601, "secret_token_123"); if (!http_server.start()) { std::cerr << "Failed to start HTTP server with auth\n"; @@ -95,7 +95,7 @@ int main() std::this_thread::sleep_for(std::chrono::milliseconds(100)); - httplib::Client client("127.0.0.1", 18401); + httplib::Client client("127.0.0.1", 18601); httplib::Headers headers = {{"Authorization", "Bearer secret_token_123"}}; Json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "test"}}; auto res = client.Post("/test", headers, request.dump(), "application/json"); @@ -119,7 +119,7 @@ int main() auto srv = std::make_shared(); srv->route("test", [](const Json&) { return Json{{"result", "ok"}}; }); - HttpServerWrapper http_server(srv, "127.0.0.1", 18402); // No CORS origin + HttpServerWrapper http_server(srv, "127.0.0.1", 18602); // No CORS origin if (!http_server.start()) { std::cerr << "Failed to start HTTP server\n"; @@ -128,7 +128,7 @@ int main() std::this_thread::sleep_for(std::chrono::milliseconds(100)); - httplib::Client client("127.0.0.1", 18402); + httplib::Client client("127.0.0.1", 18602); Json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "test"}}; auto res = client.Post("/test", request.dump(), "application/json"); @@ -158,7 +158,7 @@ int main() auto srv = std::make_shared(); srv->route("test", [](const Json&) { return Json{{"result", "ok"}}; }); - HttpServerWrapper http_server(srv, "127.0.0.1", 18403, "", "https://example.com"); + HttpServerWrapper http_server(srv, "127.0.0.1", 18603, "", "https://example.com"); if (!http_server.start()) { std::cerr << "Failed to start HTTP server\n"; @@ -167,7 +167,7 @@ int main() std::this_thread::sleep_for(std::chrono::milliseconds(100)); - httplib::Client client("127.0.0.1", 18403); + httplib::Client client("127.0.0.1", 18603); Json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "test"}}; auto res = client.Post("/test", request.dump(), "application/json"); @@ -198,7 +198,7 @@ int main() auto handler = [](const Json& req) -> Json { return Json{{"jsonrpc", "2.0"}, {"id", req["id"]}, {"result", {}}}; }; - SseServerWrapper sse_server(handler, "127.0.0.1", 18404, "/sse", "/messages", + SseServerWrapper sse_server(handler, "127.0.0.1", 18604, "/sse", "/messages", "secret_sse_token"); if (!sse_server.start()) { @@ -208,7 +208,7 @@ int main() std::this_thread::sleep_for(std::chrono::milliseconds(200)); - httplib::Client client("127.0.0.1", 18404); + httplib::Client client("127.0.0.1", 18604); auto res = client.Get("/sse"); if (!res || res->status != 401) diff --git a/tests/server/streaming_sse.cpp b/tests/server/streaming_sse.cpp index 8a1bf88..90feac4 100644 --- a/tests/server/streaming_sse.cpp +++ b/tests/server/streaming_sse.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -44,8 +45,11 @@ int main() // Start SSE receiver std::atomic sse_connected{false}; + std::atomic have_endpoint{false}; + std::string message_endpoint; std::vector seen; std::mutex seen_mutex; + std::mutex endpoint_mutex; httplib::Client sse_client("127.0.0.1", port); sse_client.set_connection_timeout(std::chrono::seconds(10)); @@ -54,18 +58,52 @@ int main() std::thread sse_thread( [&]() { + std::string buffer; auto receiver = [&](const char* data, size_t len) { sse_connected = true; - std::string chunk(data, len); - // Parse "data: {json}\n\n" blocks - if (chunk.find("data: ") == 0) + buffer.append(data, len); + + // Process complete SSE blocks separated by a blank line. + // Each block can contain lines like: + // event: endpoint + // data: /messages?session_id=... + // or: + // data: {json}\n\n + while (true) { - size_t start = 6; - size_t end = chunk.find("\n\n"); - if (end != std::string::npos) + size_t end = buffer.find("\n\n"); + if (end == std::string::npos) + break; + + std::string block = buffer.substr(0, end); + buffer.erase(0, end + 2); + + // Extract endpoint path if present + if (block.find("event: endpoint") != std::string::npos) + { + size_t data_pos = block.find("data: "); + if (data_pos != std::string::npos) + { + size_t value_start = data_pos + 6; + size_t value_end = block.find('\n', value_start); + std::string endpoint = + block.substr(value_start, value_end == std::string::npos + ? std::string::npos + : value_end - value_start); + { + std::lock_guard lock(endpoint_mutex); + message_endpoint = endpoint; + have_endpoint = !message_endpoint.empty(); + } + } + continue; + } + + // Parse "data: {json}" events and collect n values + if (block.rfind("data: ", 0) == 0) { - std::string json_str = chunk.substr(start, end - start); + std::string json_str = block.substr(6); try { Json j = Json::parse(json_str); @@ -109,12 +147,29 @@ int main() return 1; } + // Wait for server to tell us the message endpoint (includes required session_id). + for (int i = 0; i < 500 && !have_endpoint; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (!have_endpoint) + { + server->stop(); + if (sse_thread.joinable()) + sse_thread.join(); + std::cerr << "Missing endpoint event" << std::endl; + return 1; + } + // Post three messages httplib::Client post("127.0.0.1", port); + std::string post_path; + { + std::lock_guard lock(endpoint_mutex); + post_path = message_endpoint; + } for (int i = 1; i <= 3; ++i) { Json j = Json{{"n", i}}; - auto res = post.Post("/messages", j.dump(), "application/json"); + auto res = post.Post(post_path, j.dump(), "application/json"); if (!res || res->status != 200) { server->stop();