From c9195401039fdd5ce3f5ff68909b79423f5a1929 Mon Sep 17 00:00:00 2001 From: daavoo Date: Thu, 8 Jan 2026 15:08:46 +0100 Subject: [PATCH 1/5] feat: Add `mcp` support and example. - Add `cpp-httplib` as submodule. --- .github/workflows/update-llama-cpp.yml | 8 +- .gitmodules | 5 +- CMakeLists.txt | 70 ++++- deps/cpp-httplib | 1 + llama.cpp => deps/llama.cpp | 0 examples/mcp/CMakeLists.txt | 26 ++ examples/mcp/README.md | 105 ++++++++ examples/mcp/mcp.cpp | 132 ++++++++++ examples/mcp/server.py | 48 ++++ src/error.h | 11 + src/mcp/mcp_client.cpp | 340 +++++++++++++++++++++++++ src/mcp/mcp_client.h | 117 +++++++++ src/mcp/mcp_tool.cpp | 69 +++++ src/mcp/mcp_tool.h | 29 +++ tests/test_mcp_client.cpp | 173 +++++++++++++ 15 files changed, 1126 insertions(+), 8 deletions(-) create mode 160000 deps/cpp-httplib rename llama.cpp => deps/llama.cpp (100%) create mode 100644 examples/mcp/CMakeLists.txt create mode 100644 examples/mcp/README.md create mode 100644 examples/mcp/mcp.cpp create mode 100644 examples/mcp/server.py create mode 100644 src/mcp/mcp_client.cpp create mode 100644 src/mcp/mcp_client.h create mode 100644 src/mcp/mcp_tool.cpp create mode 100644 src/mcp/mcp_tool.h create mode 100644 tests/test_mcp_client.cpp diff --git a/.github/workflows/update-llama-cpp.yml b/.github/workflows/update-llama-cpp.yml index 3663b91..19973cc 100644 --- a/.github/workflows/update-llama-cpp.yml +++ b/.github/workflows/update-llama-cpp.yml @@ -28,7 +28,7 @@ jobs: - name: Check for submodule updates id: check run: | - cd llama.cpp + cd deps/llama.cpp CURRENT_COMMIT=$(git rev-parse HEAD) echo "Current commit: $CURRENT_COMMIT" @@ -69,11 +69,11 @@ jobs: git checkout -b "$BRANCH_NAME" - cd llama.cpp + cd deps/llama.cpp git checkout origin/master - cd .. + cd ../.. - git add llama.cpp + git add deps/llama.cpp git commit -m "Update llama.cpp submodule to ${{ steps.check.outputs.latest_short }}" git push origin "$BRANCH_NAME" diff --git a/.gitmodules b/.gitmodules index 0477fdd..de484ca 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "llama.cpp"] - path = llama.cpp + path = deps/llama.cpp url = https://github.com/ggerganov/llama.cpp.git +[submodule "vendor/cpp-httplib"] + path = deps/cpp-httplib + url = https://github.com/yhirose/cpp-httplib.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 001a77b..4aff93d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,7 +20,7 @@ if(AGENT_CPP_BUNDLED_LLAMA) set(LLAMA_SOURCE_DIR "${LLAMA_CPP_DIR}") message(STATUS "Using custom llama.cpp from: ${LLAMA_SOURCE_DIR}") else() - set(LLAMA_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/llama.cpp") + set(LLAMA_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp") if(NOT EXISTS "${LLAMA_SOURCE_DIR}/CMakeLists.txt") message(FATAL_ERROR "llama.cpp submodule not found at ${LLAMA_SOURCE_DIR}\n" @@ -75,6 +75,30 @@ target_include_directories(agent target_link_libraries(agent PUBLIC model common llama) target_compile_features(agent PUBLIC cxx_std_17) +# MCP Client library for connecting to MCP servers via HTTP +option(AGENT_CPP_BUILD_MCP "Build MCP client (requires OpenSSL for HTTPS)" OFF) + +if(AGENT_CPP_BUILD_MCP) + find_package(OpenSSL REQUIRED) + + add_library(mcp_client STATIC + src/mcp/mcp_client.cpp + src/mcp/mcp_tool.cpp + ) + add_library(agent-cpp::mcp_client ALIAS mcp_client) + target_include_directories(mcp_client + PUBLIC + $ + $ + $ + $ + ) + target_link_libraries(mcp_client PUBLIC common OpenSSL::SSL OpenSSL::Crypto) + target_compile_features(mcp_client PUBLIC cxx_std_17) + + message(STATUS "MCP client enabled (using cpp-httplib)") +endif() + if(AGENT_CPP_BUILD_TESTS) enable_testing() @@ -98,6 +122,19 @@ if(AGENT_CPP_BUILD_TESTS) add_test(NAME ToolTests COMMAND test_tool) add_test(NAME CallbacksTests COMMAND test_callbacks) + if(AGENT_CPP_BUILD_MCP) + add_executable(test_mcp_client tests/test_mcp_client.cpp) + target_include_directories(test_mcp_client PRIVATE + src + tests + ${LLAMA_SOURCE_DIR}/common + ) + target_link_libraries(test_mcp_client PRIVATE mcp_client common) + target_compile_features(test_mcp_client PRIVATE cxx_std_17) + + add_test(NAME MCPClientTests COMMAND test_mcp_client) + endif() + # On Windows, DLLs are placed in the bin/ directory by llama.cpp # We need to add this directory to PATH so tests can find the DLLs if(WIN32) @@ -162,6 +199,21 @@ if(AGENT_CPP_BUILD_EXAMPLES) target_link_libraries(context-engineering-example PRIVATE agent model common llama) target_compile_features(context-engineering-example PRIVATE cxx_std_17) + # MCP client example (requires MCP support) + if(AGENT_CPP_BUILD_MCP) + add_executable(mcp-example examples/mcp/mcp.cpp) + target_include_directories(mcp-example PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/examples/shared + ${LLAMA_SOURCE_DIR}/common + ${LLAMA_SOURCE_DIR}/ggml/include + ${LLAMA_SOURCE_DIR}/include + ${LLAMA_SOURCE_DIR}/vendor + ) + target_link_libraries(mcp-example PRIVATE agent model mcp_client common llama) + target_compile_features(mcp-example PRIVATE cxx_std_17) + endif() + # Note: tracing-example is not included here as it requires additional # dependencies (OpenTelemetry, protobuf, curl). Build it separately from # examples/tracing/ @@ -176,17 +228,29 @@ if(AGENT_CPP_INSTALL) include(CMakePackageConfigHelpers) # Install public headers - install(FILES + set(INSTALL_HEADERS src/agent.h src/callbacks.h src/error.h src/model.h src/tool.h + ) + + if(AGENT_CPP_BUILD_MCP) + list(APPEND INSTALL_HEADERS src/mcp/mcp_client.h src/mcp/mcp_tool.h) + endif() + + install(FILES ${INSTALL_HEADERS} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/agent-cpp ) # Install libraries with export set - install(TARGETS agent model + set(INSTALL_TARGETS agent model) + if(AGENT_CPP_BUILD_MCP) + list(APPEND INSTALL_TARGETS mcp_client) + endif() + + install(TARGETS ${INSTALL_TARGETS} EXPORT agent-cpp-targets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} diff --git a/deps/cpp-httplib b/deps/cpp-httplib new file mode 160000 index 0000000..59905c7 --- /dev/null +++ b/deps/cpp-httplib @@ -0,0 +1 @@ +Subproject commit 59905c7f0d3c796dc2673918d4ed17476c57f335 diff --git a/llama.cpp b/deps/llama.cpp similarity index 100% rename from llama.cpp rename to deps/llama.cpp diff --git a/examples/mcp/CMakeLists.txt b/examples/mcp/CMakeLists.txt new file mode 100644 index 0000000..4cc8dc1 --- /dev/null +++ b/examples/mcp/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.14) +project(mcp-example VERSION 0.1.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(AGENT_CPP_BUILD_MCP ON CACHE BOOL "Build MCP client" FORCE) + +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../.. ${CMAKE_CURRENT_BINARY_DIR}/agent-cpp) + +add_executable(mcp-example mcp.cpp) + +target_include_directories(mcp-example PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${CMAKE_CURRENT_SOURCE_DIR}/../shared + ${LLAMA_SOURCE_DIR}/common + ${LLAMA_SOURCE_DIR}/ggml/include + ${LLAMA_SOURCE_DIR}/include + ${LLAMA_SOURCE_DIR}/vendor +) + +target_link_libraries(mcp-example PRIVATE agent mcp_client common llama) +target_compile_features(mcp-example PRIVATE cxx_std_17) + +message(STATUS "MCP example configured.") diff --git a/examples/mcp/README.md b/examples/mcp/README.md new file mode 100644 index 0000000..d5b0c62 --- /dev/null +++ b/examples/mcp/README.md @@ -0,0 +1,105 @@ +# MCP Client Example + +[MCP (Model Context Protocol)](https://modelcontextprotocol.io/) is an open protocol that allows AI applications to connect to external tools and data sources. + +This example demonstrates how to connect to an MCP server via HTTP and use its tools with an agent.cpp agent. + +## Building Blocks + +### Tools + +Tools are dynamically discovered from the MCP server at runtime. The client connects to the server, performs a handshake, and retrieves the available tool definitions. + +### Callbacks + +This example uses two shared callbacks from `examples/shared/`: + +- **LoggingCallback**: Displays tool execution information with colored output showing which tools are called and their results. + +- **ErrorRecoveryCallback**: Converts tool execution errors into JSON results, allowing the agent to see errors and potentially retry or adjust. + +## Building + +> [!IMPORTANT] +> Check the [llama.cpp build documentation](https://github.com/ggml-org/llama.cpp/blob/master/docs/build.md) to find +> Cmake flags you might want to pass depending on your available hardware. + +```bash +cd examples/mcp + +git -C ../.. submodule update --init --recursive + +# MCP requires OpenSSL for HTTPS support +cmake -B build -DAGENT_CPP_BUILD_MCP=ON +cmake --build build -j$(nproc) +``` + +### Using a custom llama.cpp + +If you have llama.cpp already downloaded: + +```bash +cmake -B build -DLLAMA_CPP_DIR=/path/to/your/llama.cpp -DAGENT_CPP_BUILD_MCP=ON +cmake --build build -j$(nproc) +``` + +## Usage + +```bash +./build/mcp-example -m -u +``` + +Options: +- `-m ` - Path to the GGUF model file (required) +- `-u ` - MCP server URL (Streamable HTTP transport) (required) + +## Example + +This example includes a simple MCP server (`server.py`) with a `calculator` tool that performs basic math operations (similar to the calculator in `examples/shared`). + +### 1. Start the MCP Server + +The server uses [uv](https://docs.astral.sh/uv/) inline script metadata, so no installation is needed: + +```bash +uv run server.py +``` + +This starts the MCP server on `http://localhost:8000/mcp`. + +### 2. Run the Agent + +```bash +./build/mcp-example -m ../../granite-4.0-micro-Q8_0.gguf -u "http://localhost:8000/mcp" +``` + +### 3. Example Conversation + +```console +$ ./build/mcp-example -m ../../granite-4.0-micro-Q8_0.gguf -u "http://localhost:8000/mcp" +Connecting to MCP server: http://localhost:8000/mcp +Initializing MCP session... +MCP session initialized. + +Available tools (1): + - calculator: Perform basic mathematical operations. + +Loading model... +Model loaded successfully + +MCP Agent ready! + Connected to: http://localhost:8000/mcp + Type an empty line to quit. + +> What is 42 multiplied by 17? + + +{"name": "calculator", "arguments": "{\n \"operation\": \"multiply\",\n \"a\": 42,\n \"b\": 17\n}"} + + +[TOOL EXECUTION] Calling calculator +[TOOL RESULT] +{"result": 714} + +42 multiplied by 17 equals **714**. +``` diff --git a/examples/mcp/mcp.cpp b/examples/mcp/mcp.cpp new file mode 100644 index 0000000..b23e324 --- /dev/null +++ b/examples/mcp/mcp.cpp @@ -0,0 +1,132 @@ +#include "agent.h" +#include "chat_loop.h" +#include "error.h" +#include "error_recovery_callback.h" +#include "logging_callback.h" +#include "mcp/mcp_client.h" +#include "model.h" + +#include +#include +#include +#include +#include + +using namespace agent_cpp; + +static void +print_usage(int /*unused*/, char** argv) +{ + printf("\nexample usage:\n"); + printf("\n %s -m model.gguf -u http://localhost:8080/mcp\n", argv[0]); + printf("\n"); + printf("options:\n"); + printf(" -m Path to the GGUF model file (required)\n"); + printf(" -u MCP server URL (required)\n"); + printf("\n"); +} + +int +main(int argc, char** argv) +{ + std::string model_path; + std::string mcp_url; + + for (int i = 1; i < argc; i++) { + try { + if (strcmp(argv[i], "-m") == 0) { + if (i + 1 < argc) { + model_path = argv[++i]; + } else { + print_usage(argc, argv); + return 1; + } + } else if (strcmp(argv[i], "-u") == 0) { + if (i + 1 < argc) { + mcp_url = argv[++i]; + } else { + print_usage(argc, argv); + return 1; + } + } else { + print_usage(argc, argv); + return 1; + } + } catch (std::exception& e) { + fprintf(stderr, "error: %s\n", e.what()); + print_usage(argc, argv); + return 1; + } + } + + if (model_path.empty() || mcp_url.empty()) { + print_usage(argc, argv); + return 1; + } + + try { + printf("Connecting to MCP server: %s\n", mcp_url.c_str()); + auto mcp_client = MCPClient::create(mcp_url); + + printf("Initializing MCP session...\n"); + if (!mcp_client->initialize("agent.cpp-mcp-example", "0.1.0")) { + fprintf(stderr, "Failed to initialize MCP session\n"); + return 1; + } + + printf("MCP session initialized.\n"); + + auto tools = mcp_client->get_tools(); + + printf("\nAvailable tools (%zu):\n", tools.size()); + for (const auto& tool : tools) { + auto def = tool->get_definition(); + printf(" - %s: %s\n", def.name.c_str(), def.description.c_str()); + } + printf("\n"); + + if (tools.empty()) { + printf("No tools available from MCP server.\n"); + return 0; + } + + printf("Loading model...\n"); + std::shared_ptr model; + try { + model = Model::create(model_path); + } catch (const ModelError& e) { + fprintf(stderr, "error: %s\n", e.what()); + return 1; + } + printf("Model loaded successfully\n"); + + const std::string instructions = + "You are a helpful assistant with access to tools. " + "Use these tools to help answer user questions. "; + + std::vector> callbacks; + callbacks.push_back(std::make_unique()); + callbacks.push_back(std::make_unique()); + + Agent agent(std::move(model), + std::move(tools), + std::move(callbacks), + instructions); + + agent.load_or_create_cache("mcp.cache"); + + printf("\nMCP Agent ready!\n"); + printf(" Connected to: %s\n", mcp_url.c_str()); + printf(" Type an empty line to quit.\n\n"); + + run_chat_loop(agent); + return 0; + + } catch (const MCPError& e) { + fprintf(stderr, "MCP Error: %s\n", e.what()); + return 1; + } catch (const std::exception& e) { + fprintf(stderr, "Error: %s\n", e.what()); + return 1; + } +} diff --git a/examples/mcp/server.py b/examples/mcp/server.py new file mode 100644 index 0000000..3382084 --- /dev/null +++ b/examples/mcp/server.py @@ -0,0 +1,48 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "mcp[cli]", +# ] +# /// +from mcp.server.fastmcp import FastMCP +from typing import Literal + +mcp = FastMCP( + "agent.cpp Example Server", + host="0.0.0.0", + port=8000, +) + + +@mcp.tool() +def calculator( + operation: Literal["add", "subtract", "multiply", "divide"], + a: float, + b: float, +) -> dict: + """Perform basic mathematical operations. + + Args: + operation: The mathematical operation to perform (add, subtract, multiply, divide) + a: First operand + b: Second operand + """ + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + if b == 0: + return {"error": "Division by zero"} + result = a / b + else: + return {"error": f"Unknown operation: {operation}"} + + return {"result": result} + + +if __name__ == "__main__": + print("Starting MCP server on http://localhost:8000/mcp") + mcp.run(transport="streamable-http") diff --git a/src/error.h b/src/error.h index 2760075..d3c0fd6 100644 --- a/src/error.h +++ b/src/error.h @@ -71,6 +71,17 @@ class ToolArgumentError : public ToolError } }; +/// @brief Error during MCP client operations +/// Thrown when MCP connection, initialization, or tool calls fail. +class MCPError : public Error +{ + public: + explicit MCPError(const std::string& message) + : Error("MCP error: " + message) + { + } +}; + /// @brief Exception to intentionally skip tool execution /// This is not an error condition - it's a control flow mechanism. /// Throw from before_tool_execution callback to skip a tool. diff --git a/src/mcp/mcp_client.cpp b/src/mcp/mcp_client.cpp new file mode 100644 index 0000000..0e3b2e7 --- /dev/null +++ b/src/mcp/mcp_client.cpp @@ -0,0 +1,340 @@ +#include "mcp/mcp_client.h" +#include "error.h" +#include "mcp/mcp_tool.h" + +#include + +#define CPPHTTPLIB_OPENSSL_SUPPORT +#include + +namespace agent_cpp { + +namespace { + +// Parse URL into host and path components +void +parse_url(const std::string& url, std::string& host, std::string& path) +{ + // Find scheme end + size_t scheme_end = url.find("://"); + size_t host_start = (scheme_end != std::string::npos) ? scheme_end + 3 : 0; + + // Find path start + size_t path_start = url.find('/', host_start); + if (path_start != std::string::npos) { + host = url.substr(0, path_start); + path = url.substr(path_start); + } else { + host = url; + path = "/"; + } +} + +} // anonymous namespace + +std::shared_ptr +MCPClient::create(const std::string& url, const MCPClientConfig& config) +{ + return std::shared_ptr(new MCPClient(url, config)); +} + +MCPClient::MCPClient(const std::string& url, const MCPClientConfig& config) + : url_(url) +{ + std::string host; + parse_url(url, host, path_); + + http_client_ = std::make_unique(host); + http_client_->set_connection_timeout(config.connection_timeout_sec); + http_client_->set_read_timeout(config.read_timeout_sec); + http_client_->set_write_timeout(config.write_timeout_sec); +} + +MCPClient::~MCPClient() +{ + close(); +} + +json +MCPClient::parse_response(const std::string& response) +{ + std::istringstream stream(response); + std::string line; + std::string data; + + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + + if (line.find("data:") == 0) { + data = line.substr(5); + size_t start = data.find_first_not_of(" \t"); + if (start != std::string::npos) { + data = data.substr(start); + } + } + } + + if (data.empty()) { + return json::object(); + } + + try { + return json::parse(data); + } catch (const json::parse_error& e) { + throw MCPError("Failed to parse SSE data: " + std::string(e.what())); + } +} + +json +MCPClient::send_request(const std::string& method, const json& params) +{ + std::lock_guard lock(send_mutex_); + + int id = ++request_id_; + + json request = { { "jsonrpc", "2.0" }, { "id", id }, { "method", method } }; + + if (!params.empty()) { + request["params"] = params; + } + + std::string request_body = request.dump(); + + httplib::Headers headers = { { "Content-Type", "application/json" }, + { "Accept", + "application/json, text/event-stream" } }; + + if (!session_id_.empty()) { + headers.emplace("Mcp-Session-Id", session_id_); + } + + auto res = + http_client_->Post(path_, headers, request_body, "application/json"); + + if (!res) { + throw MCPError("HTTP request failed: " + + httplib::to_string(res.error())); + } + + if (res->status != 200) { + throw MCPError("HTTP error: " + std::to_string(res->status) + " " + + res->body); + } + + // Extract session ID from response headers + auto session_it = res->headers.find("Mcp-Session-Id"); + if (session_it != res->headers.end()) { + session_id_ = session_it->second; + } + + // Get content type + std::string content_type; + auto ct_it = res->headers.find("Content-Type"); + if (ct_it != res->headers.end()) { + content_type = ct_it->second; + } + + json response; + + if (content_type.find("text/event-stream") != std::string::npos) { + response = parse_response(res->body); + } else { + try { + response = json::parse(res->body); + } catch (const json::parse_error& e) { + throw MCPError("Failed to parse response: " + + std::string(e.what())); + } + } + + if (response.contains("error")) { + auto& error = response["error"]; + std::string msg = error.value("message", "Unknown error"); + int code = error.value("code", 0); + throw MCPError("JSON-RPC error " + std::to_string(code) + ": " + msg); + } + + return response["result"]; +} + +void +MCPClient::send_notification(const std::string& method, const json& params) +{ + std::lock_guard lock(send_mutex_); + + json notification = { { "jsonrpc", "2.0" }, { "method", method } }; + + if (!params.empty()) { + notification["params"] = params; + } + + std::string request_body = notification.dump(); + + httplib::Headers headers = { { "Content-Type", "application/json" }, + { "Accept", + "application/json, text/event-stream" } }; + + if (!session_id_.empty()) { + headers.emplace("Mcp-Session-Id", session_id_); + } + + // Fire and forget for notifications + http_client_->Post(path_, headers, request_body, "application/json"); +} + +bool +MCPClient::initialize(const std::string& client_name, + const std::string& client_version) +{ + if (initialized_) { + return true; + } + + json params = { { "protocolVersion", MCP_PROTOCOL_VERSION }, + { "capabilities", json::object() }, + { "clientInfo", + { { "name", client_name }, + { "version", client_version } } } }; + + try { + json result = send_request("initialize", params); + + if (result.contains("protocolVersion")) { + protocol_version_ = result["protocolVersion"].get(); + } + + if (result.contains("capabilities")) { + auto& caps = result["capabilities"]; + has_tools_ = caps.contains("tools"); + } + + send_notification("notifications/initialized"); + + initialized_ = true; + return true; + + } catch (const MCPError&) { + throw; + } catch (const std::exception& e) { + throw MCPError(std::string("Initialization failed: ") + e.what()); + } +} + +void +MCPClient::close() +{ + initialized_ = false; + tools_cached_ = false; + tool_cache_.clear(); + session_id_.clear(); +} + +std::vector +MCPClient::list_tools() +{ + if (!initialized_) { + throw MCPError("MCP client not initialized"); + } + + if (!has_tools_) { + return {}; + } + + if (tools_cached_) { + return tool_cache_; + } + + std::vector all_tools; + std::string cursor; + + do { + json params = json::object(); + if (!cursor.empty()) { + params["cursor"] = cursor; + } + + json result = send_request("tools/list", params); + + if (result.contains("tools") && result["tools"].is_array()) { + for (const auto& tool_json : result["tools"]) { + MCPToolDefinition tool; + tool.name = tool_json.value("name", ""); + tool.title = tool_json.value("title", ""); + tool.description = tool_json.value("description", ""); + + if (tool_json.contains("inputSchema")) { + tool.input_schema = tool_json["inputSchema"]; + } + if (tool_json.contains("outputSchema")) { + tool.output_schema = tool_json["outputSchema"]; + } + + all_tools.push_back(std::move(tool)); + } + } + + cursor.clear(); + if (result.contains("nextCursor") && !result["nextCursor"].is_null()) { + cursor = result["nextCursor"].get(); + } + + } while (!cursor.empty()); + + tool_cache_ = all_tools; + tools_cached_ = true; + + return all_tools; +} + +MCPToolResult +MCPClient::call_tool(const std::string& name, const json& arguments) +{ + if (!initialized_) { + throw MCPError("MCP client not initialized"); + } + + json params = { { "name", name }, { "arguments", arguments } }; + + json result = send_request("tools/call", params); + + MCPToolResult tool_result; + + if (result.contains("content") && result["content"].is_array()) { + for (const auto& item : result["content"]) { + MCPContentItem content_item; + content_item.type = item.value("type", ""); + content_item.text = item.value("text", ""); + content_item.data = item.value("data", ""); + content_item.mime_type = item.value("mimeType", ""); + tool_result.content.push_back(std::move(content_item)); + } + } + + if (result.contains("structuredContent")) { + tool_result.structured_content = result["structuredContent"]; + } + + tool_result.is_error = result.value("isError", false); + + return tool_result; +} + +std::vector> +MCPClient::get_tools() +{ + auto definitions = list_tools(); + + std::vector> tools; + tools.reserve(definitions.size()); + + for (auto& def : definitions) { + tools.push_back( + std::make_unique(shared_from_this(), std::move(def))); + } + + return tools; +} + +} // namespace agent_cpp diff --git a/src/mcp/mcp_client.h b/src/mcp/mcp_client.h new file mode 100644 index 0000000..1ad9e8d --- /dev/null +++ b/src/mcp/mcp_client.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "tool.h" + +// Forward declarations +namespace httplib { +class Client; +} + +namespace agent_cpp { +class MCPClient; +} + +namespace agent_cpp { + +using json = nlohmann::json; + +constexpr const char* MCP_PROTOCOL_VERSION = "2025-11-25"; + +struct MCPToolDefinition +{ + std::string name; + std::string title; + std::string description; + json input_schema; + json output_schema; +}; + +struct MCPContentItem +{ + std::string type; // "text", "image", "audio", etc. + std::string text; // For text content + std::string data; // For binary content (base64) + std::string mime_type; +}; + +struct MCPToolResult +{ + std::vector content; + json structured_content; + bool is_error = false; +}; + +/// @brief Configuration options for MCPClient +struct MCPClientConfig +{ + int connection_timeout_sec = 10; ///< Connection timeout in seconds + int read_timeout_sec = 30; ///< Read timeout in seconds + int write_timeout_sec = 10; ///< Write timeout in seconds +}; + +class MCPClient : public std::enable_shared_from_this +{ + public: + /// @brief Create an MCPClient instance + /// @param url The MCP server URL + /// @param config Optional configuration for timeouts + /// @note Use this factory method instead of direct construction to enable + /// proper lifetime management with MCPTool objects. + static std::shared_ptr create( + const std::string& url, + const MCPClientConfig& config = MCPClientConfig{}); + + ~MCPClient(); + + MCPClient(const MCPClient&) = delete; + MCPClient& operator=(const MCPClient&) = delete; + + bool initialize(const std::string& client_name = "agent.cpp", + const std::string& client_version = "0.1.0"); + + void close(); + + bool is_initialized() const { return initialized_; } + + std::vector list_tools(); + + MCPToolResult call_tool(const std::string& name, + const json& arguments = json::object()); + + std::vector> get_tools(); + + private: + MCPClient(const std::string& url, const MCPClientConfig& config); + + std::string url_; + std::string path_; + std::unique_ptr http_client_; + + std::string session_id_; + std::string protocol_version_; + bool initialized_ = false; + bool has_tools_ = false; + std::atomic request_id_{ 0 }; + std::mutex send_mutex_; + + std::vector tool_cache_; + bool tools_cached_ = false; + + json send_request(const std::string& method, + const json& params = json::object()); + + void send_notification(const std::string& method, + const json& params = json::object()); + + json parse_response(const std::string& response); +}; + +} diff --git a/src/mcp/mcp_tool.cpp b/src/mcp/mcp_tool.cpp new file mode 100644 index 0000000..059cef0 --- /dev/null +++ b/src/mcp/mcp_tool.cpp @@ -0,0 +1,69 @@ +#include "mcp/mcp_tool.h" +#include "mcp/mcp_client.h" + +namespace agent_cpp { + +MCPTool::MCPTool(std::shared_ptr client, + MCPToolDefinition definition) + : client_(std::move(client)) + , definition_(std::move(definition)) +{ +} + +common_chat_tool +MCPTool::get_definition() const +{ + common_chat_tool tool; + tool.name = definition_.name; + tool.description = definition_.description; + + if (!definition_.input_schema.is_null()) { + tool.parameters = definition_.input_schema.dump(); + } else { + tool.parameters = R"({"type": "object", "properties": {}})"; + } + + return tool; +} + +std::string +MCPTool::execute(const json& arguments) +{ + MCPToolResult result = client_->call_tool(definition_.name, arguments); + + json response; + + if (result.is_error) { + std::string error_msg; + for (const auto& item : result.content) { + if (item.type == "text") { + error_msg += item.text; + } + } + response["error"] = + error_msg.empty() ? "Tool execution error" : error_msg; + } else if (!result.structured_content.is_null()) { + response = result.structured_content; + } else { + std::string text_content; + for (const auto& item : result.content) { + if (item.type == "text") { + text_content += item.text; + } + } + + if (!text_content.empty()) { + try { + response = json::parse(text_content); + } catch (const json::parse_error&) { + response["result"] = text_content; + } + } else { + response["result"] = "success"; + } + } + + return response.dump(); +} + +} // namespace agent_cpp diff --git a/src/mcp/mcp_tool.h b/src/mcp/mcp_tool.h new file mode 100644 index 0000000..87e1cdb --- /dev/null +++ b/src/mcp/mcp_tool.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +#include + +#include "mcp/mcp_client.h" +#include "tool.h" + +namespace agent_cpp { + +using json = nlohmann::json; + +class MCPTool : public Tool +{ + public: + MCPTool(std::shared_ptr client, MCPToolDefinition definition); + + common_chat_tool get_definition() const override; + std::string execute(const json& arguments) override; + std::string get_name() const override { return definition_.name; } + + private: + std::shared_ptr client_; + MCPToolDefinition definition_; +}; + +} diff --git a/tests/test_mcp_client.cpp b/tests/test_mcp_client.cpp new file mode 100644 index 0000000..0707280 --- /dev/null +++ b/tests/test_mcp_client.cpp @@ -0,0 +1,173 @@ +#include "mcp/mcp_client.h" +#include "mcp/mcp_tool.h" +#include "test_utils.h" + +using agent_cpp::json; +using agent_cpp::MCPClient; +using agent_cpp::MCPContentItem; +using agent_cpp::MCPTool; +using agent_cpp::MCPToolDefinition; +using agent_cpp::MCPToolResult; + +namespace { + +// Test MCPToolDefinition structure +TEST(test_mcp_tool_definition) +{ + MCPToolDefinition def; + def.name = "test_tool"; + def.title = "Test Tool"; + def.description = "A test tool"; + def.input_schema = + json({ { "type", "object" }, + { "properties", { { "arg1", { { "type", "string" } } } } } }); + + ASSERT_EQ(def.name, "test_tool"); + ASSERT_EQ(def.title, "Test Tool"); + ASSERT_EQ(def.description, "A test tool"); + ASSERT_TRUE(def.input_schema.contains("type")); + ASSERT_EQ(def.input_schema["type"].get(), "object"); +} + +// Test MCPToolResult structure +TEST(test_mcp_tool_result) +{ + MCPToolResult result; + result.is_error = false; + + MCPContentItem item; + item.type = "text"; + item.text = "Hello, World!"; + result.content.push_back(item); + + result.structured_content = json({ { "message", "Hello" } }); + + ASSERT_FALSE(result.is_error); + ASSERT_EQ(result.content.size(), 1); + ASSERT_EQ(result.content[0].type, "text"); + ASSERT_EQ(result.content[0].text, "Hello, World!"); + ASSERT_EQ(result.structured_content["message"].get(), "Hello"); +} + +// Test MCPContentItem for different types +TEST(test_mcp_content_item_types) +{ + // Text content + MCPContentItem text_item; + text_item.type = "text"; + text_item.text = "Sample text"; + ASSERT_EQ(text_item.type, "text"); + ASSERT_EQ(text_item.text, "Sample text"); + + // Image content + MCPContentItem image_item; + image_item.type = "image"; + image_item.data = "base64encodeddata"; + image_item.mime_type = "image/png"; + ASSERT_EQ(image_item.type, "image"); + ASSERT_EQ(image_item.mime_type, "image/png"); +} + +// Test MCPClient creation +TEST(test_mcp_client_creation) +{ + auto client = MCPClient::create("http://localhost:8080/mcp"); + ASSERT_FALSE(client->is_initialized()); +} + +// Test MCPClient URL parsing (HTTP) +TEST(test_mcp_client_http_url) +{ + auto client = MCPClient::create("http://example.com:3000/api/mcp"); + ASSERT_FALSE(client->is_initialized()); +} + +// Test MCPClient URL parsing (HTTPS) +TEST(test_mcp_client_https_url) +{ + auto client = MCPClient::create("https://example.com/mcp"); + ASSERT_FALSE(client->is_initialized()); +} + +// Test protocol version constant +TEST(test_mcp_protocol_version) +{ + ASSERT_STREQ(agent_cpp::MCP_PROTOCOL_VERSION, "2025-11-25"); +} + +// Test MCPTool get_definition +TEST(test_mcp_tool_get_definition) +{ + auto client = MCPClient::create("http://localhost:8080/mcp"); + + // Create tool definition + MCPToolDefinition def; + def.name = "calculator"; + def.description = "Perform calculations"; + def.input_schema = json({ { "type", "object" }, + { "properties", + { { "operation", { { "type", "string" } } }, + { "a", { { "type", "number" } } }, + { "b", { { "type", "number" } } } } }, + { "required", { "operation", "a", "b" } } }); + + // Create MCPTool + MCPTool tool(client, def); + + ASSERT_EQ(tool.get_name(), "calculator"); + + auto chat_tool = tool.get_definition(); + ASSERT_EQ(chat_tool.name, "calculator"); + ASSERT_EQ(chat_tool.description, "Perform calculations"); + ASSERT_FALSE(chat_tool.parameters.empty()); + + // Verify the parameters can be parsed as JSON + json params = json::parse(chat_tool.parameters); + ASSERT_EQ(params["type"].get(), "object"); +} + +// Test MCPTool with empty schema +TEST(test_mcp_tool_empty_schema) +{ + auto client = MCPClient::create("http://localhost:8080/mcp"); + + MCPToolDefinition def; + def.name = "get_time"; + def.description = "Get current time"; + // No input schema set + + MCPTool tool(client, def); + + auto chat_tool = tool.get_definition(); + ASSERT_EQ(chat_tool.name, "get_time"); + + // Should have a default empty schema + json params = json::parse(chat_tool.parameters); + ASSERT_EQ(params["type"].get(), "object"); +} + +} + +int +main() +{ + std::cout << "\n=== Running MCP Client Unit Tests ===\n" << std::endl; + + try { + RUN_TEST(test_mcp_tool_definition); + RUN_TEST(test_mcp_tool_result); + RUN_TEST(test_mcp_content_item_types); + RUN_TEST(test_mcp_client_creation); + RUN_TEST(test_mcp_client_http_url); + RUN_TEST(test_mcp_client_https_url); + RUN_TEST(test_mcp_protocol_version); + RUN_TEST(test_mcp_tool_get_definition); + RUN_TEST(test_mcp_tool_empty_schema); + + std::cout << "\n=== All tests passed! āœ“ ===\n" << std::endl; + return 0; + } catch (const std::exception& e) { + std::cerr << "\nāœ— TEST FAILED: " << e.what() << std::endl; + return 1; + } +} From 8db1da4c33e31a3c2c2fe8e44f717f9ec5f96731 Mon Sep 17 00:00:00 2001 From: daavoo Date: Thu, 8 Jan 2026 15:09:58 +0100 Subject: [PATCH 2/5] Update llama.cpp submodule to latest commit --- deps/llama.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/llama.cpp b/deps/llama.cpp index 5b8844a..e443fbc 160000 --- a/deps/llama.cpp +++ b/deps/llama.cpp @@ -1 +1 @@ -Subproject commit 5b8844ae531d8ff09c1c00a2022293d5b674c787 +Subproject commit e443fbcfa51a8a27b15f949397ab94b5e87b2450 From 6e3a74f319b9c341e98d078c4c392cc151feae45 Mon Sep 17 00:00:00 2001 From: daavoo Date: Thu, 8 Jan 2026 15:15:45 +0100 Subject: [PATCH 3/5] fix: Use quotes for httplib. --- src/mcp/mcp_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/mcp_client.cpp b/src/mcp/mcp_client.cpp index 0e3b2e7..8731568 100644 --- a/src/mcp/mcp_client.cpp +++ b/src/mcp/mcp_client.cpp @@ -5,7 +5,7 @@ #include #define CPPHTTPLIB_OPENSSL_SUPPORT -#include +#include "httplib.h" namespace agent_cpp { From 9bc53b8158db963f4220493621f32828d3f2f04c Mon Sep 17 00:00:00 2001 From: daavoo Date: Thu, 8 Jan 2026 15:21:42 +0100 Subject: [PATCH 4/5] fix: Update lint workflow to compile with MCP. --- .github/workflows/lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fb6258b..817ddfe 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,7 +26,7 @@ jobs: - name: Install clang tools run: | sudo apt-get update - sudo apt-get install -y clang-format-14 clang-tidy-14 + sudo apt-get install -y clang-format-14 clang-tidy-14 libssl-dev sudo update-alternatives --install /usr/bin/clang-format clang-format /usr/bin/clang-format-14 100 sudo update-alternatives --install /usr/bin/clang-tidy clang-tidy /usr/bin/clang-tidy-14 100 @@ -38,7 +38,7 @@ jobs: clang-tidy --version - name: Configure CMake - run: cmake -B build -S . + run: cmake -B build -S . -DAGENT_CPP_BUILD_MCP=ON - name: Run pre-commit run: pre-commit run --all-files --show-diff-on-failure --verbose From 07242c18f2735523d517342d9309aa83ca803789 Mon Sep 17 00:00:00 2001 From: daavoo Date: Thu, 8 Jan 2026 15:30:39 +0100 Subject: [PATCH 5/5] chore: Cleanup --- src/mcp/mcp_client.cpp | 6 ------ src/mcp/mcp_client.h | 20 +++++--------------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/mcp/mcp_client.cpp b/src/mcp/mcp_client.cpp index 8731568..df37ff4 100644 --- a/src/mcp/mcp_client.cpp +++ b/src/mcp/mcp_client.cpp @@ -11,15 +11,12 @@ namespace agent_cpp { namespace { -// Parse URL into host and path components void parse_url(const std::string& url, std::string& host, std::string& path) { - // Find scheme end size_t scheme_end = url.find("://"); size_t host_start = (scheme_end != std::string::npos) ? scheme_end + 3 : 0; - // Find path start size_t path_start = url.find('/', host_start); if (path_start != std::string::npos) { host = url.substr(0, path_start); @@ -123,13 +120,11 @@ MCPClient::send_request(const std::string& method, const json& params) res->body); } - // Extract session ID from response headers auto session_it = res->headers.find("Mcp-Session-Id"); if (session_it != res->headers.end()) { session_id_ = session_it->second; } - // Get content type std::string content_type; auto ct_it = res->headers.find("Content-Type"); if (ct_it != res->headers.end()) { @@ -180,7 +175,6 @@ MCPClient::send_notification(const std::string& method, const json& params) headers.emplace("Mcp-Session-Id", session_id_); } - // Fire and forget for notifications http_client_->Post(path_, headers, request_body, "application/json"); } diff --git a/src/mcp/mcp_client.h b/src/mcp/mcp_client.h index 1ad9e8d..4e9b845 100644 --- a/src/mcp/mcp_client.h +++ b/src/mcp/mcp_client.h @@ -10,15 +10,11 @@ #include "tool.h" -// Forward declarations +// Forward declaration namespace httplib { class Client; } -namespace agent_cpp { -class MCPClient; -} - namespace agent_cpp { using json = nlohmann::json; @@ -49,22 +45,16 @@ struct MCPToolResult bool is_error = false; }; -/// @brief Configuration options for MCPClient struct MCPClientConfig { - int connection_timeout_sec = 10; ///< Connection timeout in seconds - int read_timeout_sec = 30; ///< Read timeout in seconds - int write_timeout_sec = 10; ///< Write timeout in seconds + int connection_timeout_sec = 10; + int read_timeout_sec = 30; + int write_timeout_sec = 10; }; class MCPClient : public std::enable_shared_from_this { public: - /// @brief Create an MCPClient instance - /// @param url The MCP server URL - /// @param config Optional configuration for timeouts - /// @note Use this factory method instead of direct construction to enable - /// proper lifetime management with MCPTool objects. static std::shared_ptr create( const std::string& url, const MCPClientConfig& config = MCPClientConfig{}); @@ -114,4 +104,4 @@ class MCPClient : public std::enable_shared_from_this json parse_response(const std::string& response); }; -} +} // namespace agent_cpp