From 70d6b63d5f61fc32dea99d27f6bc0d4950b0afd4 Mon Sep 17 00:00:00 2001 From: an-tao Date: Wed, 11 Mar 2026 16:16:29 +0800 Subject: [PATCH 1/3] Add MCP plugin --- CMakeLists.txt | 4 +- lib/inc/drogon/plugins/WebMCPPlugin.h | 225 ++++++++++++++++++ lib/src/WebMCPPlugin.cc | 330 ++++++++++++++++++++++++++ 3 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 lib/inc/drogon/plugins/WebMCPPlugin.h create mode 100644 lib/src/WebMCPPlugin.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 800c8e90ad..992c8b7fdf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -255,6 +255,7 @@ endif (BUILD_BROTLI) set(DROGON_SOURCES lib/src/AOPAdvice.cc lib/src/AccessLogger.cc + lib/src/WebMCPPlugin.cc lib/src/CacheFile.cc lib/src/ConfigAdapterManager.cc lib/src/ConfigLoader.cc @@ -758,7 +759,8 @@ set(DROGON_PLUGIN_HEADERS lib/inc/drogon/plugins/Hodor.h lib/inc/drogon/plugins/SlashRemover.h lib/inc/drogon/plugins/GlobalFilters.h - lib/inc/drogon/plugins/PromExporter.h) + lib/inc/drogon/plugins/PromExporter.h + lib/inc/drogon/plugins/WebMCPPlugin.h) install(FILES ${DROGON_PLUGIN_HEADERS} DESTINATION ${INSTALL_INCLUDE_DIR}/drogon/plugins) diff --git a/lib/inc/drogon/plugins/WebMCPPlugin.h b/lib/inc/drogon/plugins/WebMCPPlugin.h new file mode 100644 index 0000000000..6d5bb0bf0a --- /dev/null +++ b/lib/inc/drogon/plugins/WebMCPPlugin.h @@ -0,0 +1,225 @@ +/** + * + * WebMCPPlugin.h + * + * Drogon plugin that turns the Drogon application itself into an MCP server, + * letting C++ code register MCP tools that AI agents can call over HTTP. + * + * -------------------------------------------------------------------------- + * Quick start + * -------------------------------------------------------------------------- + * + * 1. Add the plugin to your config.json: + * + * @code + * { + * "name": "drogon::plugin::WebMCPPlugin", + * "config": { + * "path": "/mcp" + * } + * } + * @endcode + * + * 2. Register tools anywhere in your application code (e.g. main.cc or a + * controller constructor) *before* app().run(): + * + * @code + * #include + * + * auto *mcp = app().getPlugin(); + * + * mcp->registerTool( + * "recognize_image", // tool name + * "Detect objects in a JPEG/PNG image", // description shown to agent + * { // input schema parameters + * {"image_base64", "string", "Base64-encoded image data", true}, + * {"min_confidence", "number", "Minimum confidence 0-1", false}, + * }, + * [](const Json::Value &args, + * drogon::plugin::ToolResultCallback &&cb) { + * auto b64 = args["image_base64"].asString(); + * double conf = args.get("min_confidence", 0.5).asDouble(); + * std::string label = myRecognizer.run(b64, conf); + * cb(drogon::plugin::ToolResult::text(label)); + * }); + * @endcode + * + * -------------------------------------------------------------------------- + * HTTP endpoints (MCP Streamable-HTTP transport, 2025-03-26 spec) + * -------------------------------------------------------------------------- + * + * POST {path} — JSON-RPC 2.0 endpoint (initialize / tools/list / + * tools/call / ping …) + * GET {path} — SSE stream for server-initiated messages (optional) + * DELETE {path} — terminate a session + * + * -------------------------------------------------------------------------- + * Supported MCP methods + * -------------------------------------------------------------------------- + * initialize — capability negotiation + * ping — keep-alive + * tools/list — enumerate registered tools + * tools/call — invoke a tool by name + * + * -------------------------------------------------------------------------- + * Configuration fields + * -------------------------------------------------------------------------- + * path (string, default "/mcp") — HTTP endpoint path + * server_name (string, default "drogon") — MCP serverInfo.name + * server_version(string, default "1.0.0") — MCP serverInfo.version + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace drogon +{ +namespace plugin +{ + +// --------------------------------------------------------------------------- +// ToolResult — what a tool handler returns to the agent +// --------------------------------------------------------------------------- +struct ToolResult +{ + // content array as defined by the MCP spec + Json::Value content; + bool isError{false}; + + // Convenience factories + static ToolResult text(const std::string &text) + { + ToolResult r; + Json::Value item; + item["type"] = "text"; + item["text"] = text; + r.content.append(item); + return r; + } + + static ToolResult error(const std::string &message) + { + ToolResult r; + r.isError = true; + Json::Value item; + item["type"] = "text"; + item["text"] = message; + r.content.append(item); + return r; + } + + static ToolResult json(const Json::Value &value) + { + ToolResult r; + Json::StreamWriterBuilder w; + Json::Value item; + item["type"] = "text"; + item["text"] = Json::writeString(w, value); + r.content.append(item); + return r; + } +}; + +// Callback the tool handler must invoke (exactly once) with its result. +using ToolResultCallback = std::function; + +// Tool handler signature. +using ToolHandler = std::function; + +// --------------------------------------------------------------------------- +// ToolParam — describes one parameter in the JSON Schema input object +// --------------------------------------------------------------------------- +struct ToolParam +{ + std::string name; + // JSON Schema type: "string" | "number" | "integer" | "boolean" | "object" + std::string type{"string"}; + std::string description; + bool required{false}; +}; + +// --------------------------------------------------------------------------- +// WebMCPPlugin +// --------------------------------------------------------------------------- +class DROGON_EXPORT WebMCPPlugin : public drogon::Plugin +{ + public: + WebMCPPlugin() = default; + + void initAndStart(const Json::Value &config) override; + void shutdown() override; + + /** + * @brief Register a tool that AI agents can discover and call. + * + * Thread-safe; may be called before or after app().run(). + * + * @param name Unique tool name (snake_case recommended). + * @param description Human-readable description sent to the agent. + * @param params Input parameter descriptors. + * @param handler Function called when the agent invokes the tool. + * Must call the supplied ToolResultCallback exactly + * once. + */ + void registerTool(const std::string &name, + const std::string &description, + std::vector params, + ToolHandler handler); + + private: + struct ToolEntry + { + std::string name; + std::string description; + std::vector params; + ToolHandler handler; + + // Pre-built JSON Schema object for tools/list + Json::Value inputSchema; + }; + + std::string path_{"/mcp"}; + std::string serverName_{"drogon"}; + std::string serverVersion_{"1.0.0"}; + + mutable std::mutex toolsMutex_; + std::vector tools_; + + void registerRoutes(); + + // JSON-RPC dispatcher + void handlePost(const HttpRequestPtr &req, + std::function &&cb) const; + + // Individual MCP method handlers (return JSON-RPC result or error object) + Json::Value handleInitialize(const Json::Value ¶ms) const; + Json::Value handleToolsList(const Json::Value ¶ms) const; + void handleToolsCall(const Json::Value &id, + const Json::Value ¶ms, + std::function cb) const; + + // Build a JSON-RPC 2.0 success response + static HttpResponsePtr rpcOk(const Json::Value &id, + const Json::Value &result); + // Build a JSON-RPC 2.0 error response + static HttpResponsePtr rpcError(const Json::Value &id, + int code, + const std::string &message); + + // Build inputSchema from ToolParam list + static Json::Value buildInputSchema(const std::vector ¶ms); +}; + +} // namespace plugin +} // namespace drogon diff --git a/lib/src/WebMCPPlugin.cc b/lib/src/WebMCPPlugin.cc new file mode 100644 index 0000000000..3005a4f471 --- /dev/null +++ b/lib/src/WebMCPPlugin.cc @@ -0,0 +1,330 @@ +/** + * + * WebMCPPlugin.cc + * + */ + +#include +#include +#include + +#include + +#include +#include +#include + +namespace drogon +{ +namespace plugin +{ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static Json::Value parseBody(const HttpRequestPtr &req, std::string &errs) +{ + Json::Value root; + Json::CharReaderBuilder builder; + std::istringstream ss(req->getBody()); + Json::parseFromStream(builder, ss, &root, &errs); + return root; +} + +static std::string jsonStr(const Json::Value &v) +{ + Json::StreamWriterBuilder w; + w["indentation"] = ""; + return Json::writeString(w, v); +} + +// --------------------------------------------------------------------------- +// WebMCPPlugin — lifecycle +// --------------------------------------------------------------------------- + +void WebMCPPlugin::initAndStart(const Json::Value &config) +{ + if (config.isMember("path") && config["path"].isString()) + path_ = config["path"].asString(); + if (path_.empty() || path_[0] != '/') + path_ = "/" + path_; + + if (config.isMember("server_name") && config["server_name"].isString()) + serverName_ = config["server_name"].asString(); + if (config.isMember("server_version") && + config["server_version"].isString()) + serverVersion_ = config["server_version"].asString(); + + registerRoutes(); + + LOG_INFO << "WebMCPPlugin: MCP endpoint ready at POST " << path_; +} + +void WebMCPPlugin::shutdown() +{ + std::lock_guard lock(toolsMutex_); + tools_.clear(); +} + +// --------------------------------------------------------------------------- +// Public API — registerTool +// --------------------------------------------------------------------------- + +void WebMCPPlugin::registerTool(const std::string &name, + const std::string &description, + std::vector params, + ToolHandler handler) +{ + ToolEntry entry; + entry.name = name; + entry.description = description; + entry.params = std::move(params); + entry.handler = std::move(handler); + entry.inputSchema = buildInputSchema(entry.params); + + std::lock_guard lock(toolsMutex_); + // Replace if a tool with the same name already exists + for (auto &e : tools_) + { + if (e.name == name) + { + e = std::move(entry); + LOG_INFO << "WebMCPPlugin: updated tool '" << name << "'"; + return; + } + } + tools_.push_back(std::move(entry)); + LOG_INFO << "WebMCPPlugin: registered tool '" << name << "'"; +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +void WebMCPPlugin::registerRoutes() +{ + drogon::app().registerHandler( + path_, + [this](const HttpRequestPtr &req, + std::function &&cb) { + handlePost(req, std::move(cb)); + }, + {Post}); + + // GET — minimal SSE endpoint so clients that open a stream don't get 404 + drogon::app().registerHandler( + path_, + [](const HttpRequestPtr & /*req*/, + std::function &&cb) { + auto resp = HttpResponse::newHttpResponse(); + resp->setStatusCode(k200OK); + resp->addHeader("Content-Type", "text/event-stream"); + resp->addHeader("Cache-Control", "no-cache"); + resp->setBody(""); + cb(resp); + }, + {Get}); +} + +// --------------------------------------------------------------------------- +// JSON-RPC dispatcher +// --------------------------------------------------------------------------- + +void WebMCPPlugin::handlePost( + const HttpRequestPtr &req, + std::function &&cb) const +{ + std::string errs; + Json::Value body = parseBody(req, errs); + + if (!errs.empty() || body.isNull()) + { + cb(rpcError(Json::Value::null, -32700, "Parse error: " + errs)); + return; + } + + // Support both single request and batch (array) + if (body.isArray()) + { + // Batch: not commonly used by MCP clients but handle gracefully + cb(rpcError(Json::Value::null, -32600, "Batch requests not supported")); + return; + } + + const Json::Value &id = + body.isMember("id") ? body["id"] : Json::Value::null; + const std::string method = body.get("method", "").asString(); + const Json::Value ¶ms = body.isMember("params") + ? body["params"] + : Json::Value(Json::objectValue); + + if (method == "initialize") + { + cb(rpcOk(id, handleInitialize(params))); + } + else if (method == "ping") + { + cb(rpcOk(id, Json::Value(Json::objectValue))); + } + else if (method == "tools/list") + { + cb(rpcOk(id, handleToolsList(params))); + } + else if (method == "tools/call") + { + // tools/call is async — the handler calls back when ready + handleToolsCall(id, params, std::move(cb)); + } + else if (method.empty() || body.get("jsonrpc", "").asString() != "2.0") + { + cb(rpcError(id, -32600, "Invalid Request")); + } + else + { + cb(rpcError(id, -32601, "Method not found: " + method)); + } +} + +// --------------------------------------------------------------------------- +// MCP method implementations +// --------------------------------------------------------------------------- + +Json::Value WebMCPPlugin::handleInitialize(const Json::Value & /*params*/) const +{ + Json::Value result; + result["protocolVersion"] = "2025-03-26"; + result["serverInfo"]["name"] = serverName_; + result["serverInfo"]["version"] = serverVersion_; + result["capabilities"]["tools"]["listChanged"] = false; + return result; +} + +Json::Value WebMCPPlugin::handleToolsList(const Json::Value & /*params*/) const +{ + Json::Value toolsArray(Json::arrayValue); + + std::lock_guard lock(toolsMutex_); + for (const auto &entry : tools_) + { + Json::Value tool; + tool["name"] = entry.name; + tool["description"] = entry.description; + tool["inputSchema"] = entry.inputSchema; + toolsArray.append(tool); + } + + Json::Value result; + result["tools"] = toolsArray; + return result; +} + +void WebMCPPlugin::handleToolsCall( + const Json::Value &id, + const Json::Value ¶ms, + std::function cb) const +{ + const std::string toolName = params.get("name", "").asString(); + const Json::Value &arguments = params.isMember("arguments") + ? params["arguments"] + : Json::Value(Json::objectValue); + + // Find the tool + ToolHandler handler; + { + std::lock_guard lock(toolsMutex_); + for (const auto &entry : tools_) + { + if (entry.name == toolName) + { + handler = entry.handler; + break; + } + } + } + + if (!handler) + { + cb(rpcError(id, -32602, "Tool not found: " + toolName)); + return; + } + + // Invoke the handler; it calls back (possibly on another thread) with + // result + handler(arguments, [id, cb = std::move(cb)](ToolResult &&result) { + Json::Value mcpResult; + mcpResult["content"] = result.content; + if (result.isError) + mcpResult["isError"] = true; + cb(rpcOk(id, mcpResult)); + }); +} + +// --------------------------------------------------------------------------- +// JSON-RPC response builders +// --------------------------------------------------------------------------- + +HttpResponsePtr WebMCPPlugin::rpcOk(const Json::Value &id, + const Json::Value &result) +{ + Json::Value body; + body["jsonrpc"] = "2.0"; + body["id"] = id; + body["result"] = result; + + auto resp = HttpResponse::newHttpResponse(); + resp->setStatusCode(k200OK); + resp->setContentTypeCode(CT_APPLICATION_JSON); + resp->setBody(jsonStr(body)); + return resp; +} + +HttpResponsePtr WebMCPPlugin::rpcError(const Json::Value &id, + int code, + const std::string &message) +{ + Json::Value err; + err["code"] = code; + err["message"] = message; + + Json::Value body; + body["jsonrpc"] = "2.0"; + body["id"] = id; + body["error"] = err; + + auto resp = HttpResponse::newHttpResponse(); + resp->setStatusCode(k200OK); // JSON-RPC errors use HTTP 200 + resp->setContentTypeCode(CT_APPLICATION_JSON); + resp->setBody(jsonStr(body)); + return resp; +} + +// --------------------------------------------------------------------------- +// Schema builder +// --------------------------------------------------------------------------- + +Json::Value WebMCPPlugin::buildInputSchema(const std::vector ¶ms) +{ + Json::Value schema; + schema["type"] = "object"; + schema["properties"] = Json::Value(Json::objectValue); + Json::Value required(Json::arrayValue); + + for (const auto &p : params) + { + Json::Value prop; + prop["type"] = p.type; + prop["description"] = p.description; + schema["properties"][p.name] = prop; + if (p.required) + required.append(p.name); + } + + if (!required.empty()) + schema["required"] = required; + + return schema; +} + +} // namespace plugin +} // namespace drogon From 7addf1edcda4f117f671732f2421d76d63b9871f Mon Sep 17 00:00:00 2001 From: an-tao Date: Wed, 11 Mar 2026 16:44:39 +0800 Subject: [PATCH 2/3] Fix --- lib/src/WebMCPPlugin.cc | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/lib/src/WebMCPPlugin.cc b/lib/src/WebMCPPlugin.cc index 3005a4f471..4d7dc81fa2 100644 --- a/lib/src/WebMCPPlugin.cc +++ b/lib/src/WebMCPPlugin.cc @@ -11,7 +11,6 @@ #include #include -#include #include namespace drogon @@ -23,15 +22,6 @@ namespace plugin // Helpers // --------------------------------------------------------------------------- -static Json::Value parseBody(const HttpRequestPtr &req, std::string &errs) -{ - Json::Value root; - Json::CharReaderBuilder builder; - std::istringstream ss(req->getBody()); - Json::parseFromStream(builder, ss, &root, &errs); - return root; -} - static std::string jsonStr(const Json::Value &v) { Json::StreamWriterBuilder w; @@ -135,14 +125,15 @@ void WebMCPPlugin::handlePost( const HttpRequestPtr &req, std::function &&cb) const { - std::string errs; - Json::Value body = parseBody(req, errs); - - if (!errs.empty() || body.isNull()) + auto jsonPtr = req->getJsonObject(); + if (!jsonPtr) { - cb(rpcError(Json::Value::null, -32700, "Parse error: " + errs)); + cb(rpcError(Json::Value::null, + -32700, + "Parse error: " + req->getJsonError())); return; } + const Json::Value &body = *jsonPtr; // Support both single request and batch (array) if (body.isArray()) From cbd76ce3d95a58e4bf5e3d50bad57a96121e0baf Mon Sep 17 00:00:00 2001 From: an-tao Date: Wed, 11 Mar 2026 17:09:38 +0800 Subject: [PATCH 3/3] Comments --- lib/inc/drogon/plugins/WebMCPPlugin.h | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/inc/drogon/plugins/WebMCPPlugin.h b/lib/inc/drogon/plugins/WebMCPPlugin.h index 6d5bb0bf0a..2388fe381b 100644 --- a/lib/inc/drogon/plugins/WebMCPPlugin.h +++ b/lib/inc/drogon/plugins/WebMCPPlugin.h @@ -15,7 +15,9 @@ * { * "name": "drogon::plugin::WebMCPPlugin", * "config": { - * "path": "/mcp" + * "path": "/mcp", + * "server_name": "my-app", + * "server_version": "1.0.0" * } * } * @endcode @@ -48,10 +50,14 @@ * HTTP endpoints (MCP Streamable-HTTP transport, 2025-03-26 spec) * -------------------------------------------------------------------------- * - * POST {path} — JSON-RPC 2.0 endpoint (initialize / tools/list / - * tools/call / ping …) - * GET {path} — SSE stream for server-initiated messages (optional) - * DELETE {path} — terminate a session + * POST {path} — JSON-RPC 2.0 endpoint; handles all MCP method calls. + * Responses are returned as a single HTTP reply (not + * streamed), because Drogon does not support chunked + * transfer encoding / true SSE push. + * GET {path} — Returns an empty text/event-stream response (HTTP 200) + * so that MCP clients that open an SSE channel do not + * receive a 404. No events are pushed over this channel; + * all data flows through POST responses. * * -------------------------------------------------------------------------- * Supported MCP methods @@ -64,9 +70,13 @@ * -------------------------------------------------------------------------- * Configuration fields * -------------------------------------------------------------------------- - * path (string, default "/mcp") — HTTP endpoint path - * server_name (string, default "drogon") — MCP serverInfo.name - * server_version(string, default "1.0.0") — MCP serverInfo.version + * path (string, default "/mcp") — HTTP endpoint prefix. + * Must start with '/'; a leading slash is added + * automatically if omitted. + * server_name (string, default "drogon") — Value of serverInfo.name + * returned in the initialize response. + * server_version (string, default "1.0.0") — Value of + * serverInfo.version returned in the initialize response. */ #pragma once