From ac230b195d00b86986366a540411a0bbb8219801 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Thu, 27 Nov 2025 19:38:32 +0800 Subject: [PATCH] feat: Add MCP server mode (--mcp-server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AISSIA can now run as an MCP server, exposing tools via JSON-RPC over stdio. This allows external clients (like Claude Code) to use AISSIA's tools. - Add MCPServer.hpp/cpp implementing MCP protocol - Add --mcp-server flag to main.cpp - Currently exposes 6 FileSystem tools - Update documentation (CLAUDE.md, SUCCESSION.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 3 + CMakeLists.txt | 3 +- docs/SUCCESSION.md | 50 ++++++- src/main.cpp | 47 ++++++ src/shared/mcp/MCPServer.cpp | 276 +++++++++++++++++++++++++++++++++++ src/shared/mcp/MCPServer.hpp | 85 +++++++++++ 6 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 src/shared/mcp/MCPServer.cpp create mode 100644 src/shared/mcp/MCPServer.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 6db1d9a..8ecb5c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,9 @@ cmake -B build && cmake --build build -j4 # Hot-reload: rebuild modules seulement cmake --build build --target modules + +# Mode MCP Server (expose tools via JSON-RPC stdio) +./build/aissia --mcp-server ``` ## Documentation diff --git a/CMakeLists.txt b/CMakeLists.txt index e0920b8..4fb5815 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,12 +73,13 @@ if(OPENSSL_FOUND) target_compile_definitions(AissiaLLM PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) endif() -# Tools Library (Internal tools + FileSystem tools + MCP client) +# Tools Library (Internal tools + FileSystem tools + MCP client + MCP server) add_library(AissiaTools STATIC src/shared/tools/InternalTools.cpp src/shared/tools/FileSystemTools.cpp src/shared/mcp/StdioTransport.cpp src/shared/mcp/MCPClient.cpp + src/shared/mcp/MCPServer.cpp ) target_include_directories(AissiaTools PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src diff --git a/docs/SUCCESSION.md b/docs/SUCCESSION.md index 1b9e9bd..df4199c 100644 --- a/docs/SUCCESSION.md +++ b/docs/SUCCESSION.md @@ -28,6 +28,9 @@ cmake -B build && cmake --build build -j4 # Run (depuis racine ou build/) ./build/aissia +# Mode MCP Server (expose les tools via JSON-RPC stdio) +./build/aissia --mcp-server + # Tests cmake -B build -DBUILD_TESTING=ON ./build/tests/aissia_tests "[scheduler],[notification]" # Modules @@ -123,7 +126,8 @@ src/modules/StorageModule.* ### MCP ``` src/shared/mcp/MCPTypes.hpp -src/shared/mcp/MCPClient.* +src/shared/mcp/MCPClient.* # Client MCP (consomme des serveurs externes) +src/shared/mcp/MCPServer.* # Serveur MCP (expose AISSIA comme serveur) src/shared/mcp/StdioTransport.* config/mcp.json ``` @@ -164,6 +168,50 @@ cmake -B build -DBUILD_TESTING=ON && cmake --build build -j4 7. **CI/CD** - GitHub Actions 8. **Documentation API** - Doxygen +## MCP Server Mode + +AISSIA peut fonctionner comme **serveur MCP**, exposant ses tools à des clients externes via JSON-RPC sur stdio. + +```bash +./build/aissia --mcp-server +``` + +### Protocole + +Communication JSON-RPC 2.0 sur stdin/stdout : + +```json +// Client → AISSIA +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"1.0"}}} + +// AISSIA → Client +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"aissia","version":"0.2.0"},...}} + +// Lister les tools +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + +// Appeler un tool +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_directory","arguments":{"path":"."}}} +``` + +### Utilisation avec Claude Code + +Ajouter dans la config MCP : +```json +{ + "servers": { + "aissia": { + "command": "/chemin/vers/build/aissia", + "args": ["--mcp-server"] + } + } +} +``` + +### Tools Exposés (actuellement) + +6 FileSystem tools. TODO: exposer les tools internes (scheduler, voice, etc.). + ## Notes Techniques ### WSL diff --git a/src/main.cpp b/src/main.cpp index c254367..0a87600 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,9 @@ #include "services/StorageService.hpp" #include "services/PlatformService.hpp" #include "services/VoiceService.hpp" +#include "shared/mcp/MCPServer.hpp" +#include "shared/tools/FileSystemTools.hpp" +#include "shared/llm/ToolRegistry.hpp" #include #include @@ -16,6 +19,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -155,7 +159,49 @@ private: std::map m_serviceIOs; }; +// Run AISSIA as MCP server (stdio mode) +int runMCPServer() { + // Log to stderr so stdout stays clean for JSON-RPC + // Use stderr_color_sink from stdout_color_sinks.h + auto logger = spdlog::stderr_color_mt("MCPServer"); + spdlog::set_default_logger(logger); + spdlog::set_level(spdlog::level::info); + + spdlog::info("AISSIA MCP Server starting..."); + + // Create tool registry with FileSystem tools + aissia::ToolRegistry registry; + + // Register FileSystem tools + for (const auto& toolDef : aissia::tools::FileSystemTools::getToolDefinitions()) { + std::string toolName = toolDef["name"].get(); + registry.registerTool( + toolName, + toolDef["description"].get(), + toolDef["input_schema"], + [toolName](const nlohmann::json& input) -> nlohmann::json { + return aissia::tools::FileSystemTools::execute(toolName, input); + } + ); + } + + spdlog::info("Registered {} tools", registry.size()); + + // Create and run MCP server + aissia::mcp::MCPServer server(registry); + server.setServerInfo("aissia", "0.2.0"); + server.run(); + + return 0; +} + int main(int argc, char* argv[]) { + // Check for MCP server mode + if (argc > 1 && (std::strcmp(argv[1], "--mcp-server") == 0 || + std::strcmp(argv[1], "--mcp") == 0)) { + return runMCPServer(); + } + // Setup logging auto console = spdlog::stdout_color_mt("Aissia"); spdlog::set_default_logger(console); @@ -166,6 +212,7 @@ int main(int argc, char* argv[]) { spdlog::info(" AISSIA - Assistant Personnel IA"); spdlog::info(" Powered by GroveEngine"); spdlog::info(" Architecture: Services + Hot-Reload Modules"); + spdlog::info(" (Use --mcp-server to run as MCP server)"); spdlog::info("========================================"); // Signal handling diff --git a/src/shared/mcp/MCPServer.cpp b/src/shared/mcp/MCPServer.cpp new file mode 100644 index 0000000..49878a1 --- /dev/null +++ b/src/shared/mcp/MCPServer.cpp @@ -0,0 +1,276 @@ +#include "MCPServer.hpp" +#include + +#include +#include + +namespace aissia::mcp { + +MCPServer::MCPServer(aissia::ToolRegistry& registry) : m_registry(registry) { + m_logger = spdlog::get("MCPServer"); + if (!m_logger) { + // Log to stderr so stdout stays clean for JSON-RPC + m_logger = spdlog::stderr_color_mt("MCPServer"); + } + + // Default capabilities + m_capabilities = { + {"tools", {{"listChanged", false}}} + }; +} + +MCPServer::~MCPServer() { + stop(); +} + +void MCPServer::setServerInfo(const std::string& name, const std::string& version) { + m_serverName = name; + m_serverVersion = version; +} + +void MCPServer::setCapabilities(const json& capabilities) { + m_capabilities = capabilities; +} + +void MCPServer::run() { + m_running = true; + m_logger->info("MCP Server starting (stdio mode)"); + + while (m_running) { + std::string line = readLine(); + if (line.empty()) { + if (m_running) { + m_logger->debug("EOF received, stopping server"); + } + break; + } + + processLine(line); + } + + m_logger->info("MCP Server stopped"); +} + +void MCPServer::stop() { + m_running = false; +} + +std::string MCPServer::readLine() { + std::string line; + if (!std::getline(std::cin, line)) { + return ""; + } + // Remove trailing \r if present (Windows line endings) + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + return line; +} + +void MCPServer::writeLine(const std::string& line) { + std::cout << line << std::endl; + std::cout.flush(); +} + +void MCPServer::processLine(const std::string& line) { + m_logger->debug("Received: {}", line.substr(0, 200)); + + try { + json message = json::parse(line); + + // Validate JSON-RPC + if (!message.contains("jsonrpc") || message["jsonrpc"] != "2.0") { + sendError(-1, -32600, "Invalid JSON-RPC: missing or invalid jsonrpc field"); + return; + } + + // Check if it's a request (has method) or notification (no id) + if (!message.contains("method")) { + sendError(message.value("id", -1), -32600, "Invalid Request: missing method"); + return; + } + + JsonRpcRequest request; + request.method = message["method"].get(); + request.params = message.value("params", json::object()); + request.id = message.value("id", 0); + + bool isNotification = !message.contains("id"); + + json result = handleRequest(request); + + // Only send response for requests, not notifications + if (!isNotification) { + if (result.contains("error")) { + sendError(request.id, result["error"]["code"].get(), + result["error"]["message"].get()); + } else { + sendResponse(request.id, result); + } + } + + } catch (const json::exception& e) { + m_logger->error("JSON parse error: {}", e.what()); + sendError(-1, -32700, std::string("Parse error: ") + e.what()); + } +} + +json MCPServer::handleRequest(const JsonRpcRequest& request) { + m_logger->debug("Handling method: {}", request.method); + + // MCP methods + if (request.method == "initialize") { + return handleInitialize(request.params); + } else if (request.method == "ping") { + return handlePing(request.params); + } else if (request.method == "tools/list") { + return handleToolsList(request.params); + } else if (request.method == "tools/call") { + return handleToolsCall(request.params); + } else if (request.method == "notifications/initialized") { + // Client notification that initialization is complete + m_logger->info("Client initialized"); + return json::object(); // No response for notifications + } + + // Unknown method + return {{"error", {{"code", -32601}, {"message", "Method not found: " + request.method}}}}; +} + +json MCPServer::handleInitialize(const json& params) { + if (m_initialized) { + return {{"error", {{"code", -32600}, {"message", "Already initialized"}}}}; + } + + // Extract client info + if (params.contains("clientInfo")) { + m_clientName = params["clientInfo"].value("name", "unknown"); + m_clientVersion = params["clientInfo"].value("version", "unknown"); + m_logger->info("Client connected: {} v{}", m_clientName, m_clientVersion); + } + + m_initialized = true; + + // Return server info and capabilities + return { + {"protocolVersion", "2024-11-05"}, + {"serverInfo", { + {"name", m_serverName}, + {"version", m_serverVersion} + }}, + {"capabilities", m_capabilities} + }; +} + +json MCPServer::handleToolsList(const json& params) { + if (!m_initialized) { + return {{"error", {{"code", -32600}, {"message", "Not initialized"}}}}; + } + + // Get tool definitions from registry and convert to MCP format + json registryTools = m_registry.getToolDefinitions(); + json tools = json::array(); + + for (const auto& tool : registryTools) { + json toolEntry; + toolEntry["name"] = tool["name"]; + toolEntry["description"] = tool["description"]; + toolEntry["inputSchema"] = tool["input_schema"]; + tools.push_back(toolEntry); + } + + return {{"tools", tools}}; +} + +json MCPServer::handleToolsCall(const json& params) { + if (!m_initialized) { + return {{"error", {{"code", -32600}, {"message", "Not initialized"}}}}; + } + + if (!params.contains("name")) { + return {{"error", {{"code", -32602}, {"message", "Missing tool name"}}}}; + } + + std::string toolName = params["name"].get(); + json arguments = params.value("arguments", json::object()); + + m_logger->info("Calling tool: {}", toolName); + + try { + json result = m_registry.execute(toolName, arguments); + + // Format result as MCP content + json content = json::array(); + + // Convert result to text content + std::string textContent; + if (result.is_string()) { + textContent = result.get(); + } else { + textContent = result.dump(2); + } + + json contentEntry; + contentEntry["type"] = "text"; + contentEntry["text"] = textContent; + content.push_back(contentEntry); + + return { + {"content", content}, + {"isError", false} + }; + + } catch (const std::exception& e) { + m_logger->error("Tool execution failed: {}", e.what()); + json errorContent = json::array(); + json errorEntry; + errorEntry["type"] = "text"; + errorEntry["text"] = e.what(); + errorContent.push_back(errorEntry); + + json errorResult; + errorResult["content"] = errorContent; + errorResult["isError"] = true; + return errorResult; + } +} + +json MCPServer::handlePing(const json& params) { + return json::object(); // Empty response = pong +} + +void MCPServer::sendResponse(int id, const json& result) { + json response = { + {"jsonrpc", "2.0"}, + {"id", id}, + {"result", result} + }; + std::string line = response.dump(); + m_logger->debug("Sending: {}", line.substr(0, 200)); + writeLine(line); +} + +void MCPServer::sendError(int id, int code, const std::string& message) { + json response = { + {"jsonrpc", "2.0"}, + {"id", id}, + {"error", { + {"code", code}, + {"message", message} + }} + }; + writeLine(response.dump()); +} + +void MCPServer::sendNotification(const std::string& method, const json& params) { + json notification = { + {"jsonrpc", "2.0"}, + {"method", method} + }; + if (!params.empty()) { + notification["params"] = params; + } + writeLine(notification.dump()); +} + +} // namespace aissia::mcp diff --git a/src/shared/mcp/MCPServer.hpp b/src/shared/mcp/MCPServer.hpp new file mode 100644 index 0000000..84f555e --- /dev/null +++ b/src/shared/mcp/MCPServer.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include "MCPTypes.hpp" +#include "../llm/ToolRegistry.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace aissia::mcp { + +using json = nlohmann::json; + +/** + * @brief MCP Server - Exposes AISSIA tools via MCP protocol + * + * Allows external clients (like Claude Code) to connect to AISSIA + * and use its tools via JSON-RPC over stdio. + * + * Usage: + * MCPServer server(toolRegistry); + * server.setServerInfo("aissia", "1.0.0"); + * server.run(); // Blocking - reads stdin, writes stdout + * + * Or in a thread: + * std::thread serverThread([&]() { server.run(); }); + */ +class MCPServer { +public: + explicit MCPServer(aissia::ToolRegistry& registry); + ~MCPServer(); + + // Server configuration + void setServerInfo(const std::string& name, const std::string& version); + void setCapabilities(const json& capabilities); + + // Run the server (blocking) + void run(); + + // Stop the server + void stop(); + + // Check if running + bool isRunning() const { return m_running; } + +private: + // JSON-RPC handling + void processLine(const std::string& line); + json handleRequest(const JsonRpcRequest& request); + + // MCP method handlers + json handleInitialize(const json& params); + json handleToolsList(const json& params); + json handleToolsCall(const json& params); + json handlePing(const json& params); + + // IO + std::string readLine(); + void writeLine(const std::string& line); + void sendResponse(int id, const json& result); + void sendError(int id, int code, const std::string& message); + void sendNotification(const std::string& method, const json& params = json::object()); + + // State + aissia::ToolRegistry& m_registry; + std::atomic m_running{false}; + bool m_initialized{false}; + + // Server info + std::string m_serverName{"aissia"}; + std::string m_serverVersion{"1.0.0"}; + json m_capabilities; + + // Client info (from initialize) + std::string m_clientName; + std::string m_clientVersion; + + std::shared_ptr m_logger; +}; + +} // namespace aissia::mcp