Testing MCP Applications

This guide shows how to test MCP servers and clients that you build using the mcp-cpp-sdk. The SDK provides specialized tools and patterns to make testing asynchronous, bidirectional communication straightforward and deterministic.

Overview

When testing your MCP applications, you should focus on three levels of verification:

  1. Unit Tests: Testing individual tool handlers, resource providers, or middleware in isolation.

  2. Integration Tests: Testing the interaction between your Server and a Client using in-memory communication.

  3. End-to-End (E2E) Tests: Testing the full stack, including real I/O (Stdio, HTTP) and external dependencies.

This guide focuses primarily on Integration and E2E testing using the built-in mcp::MemoryTransport.

MemoryTransport for Testing

The mcp::MemoryTransport is an in-process, bidirectional transport that allows a Server and a Client to communicate without any network or process overhead. It’s the recommended way to write fast, reliable integration tests.

Key features of MemoryTransport:

  • In-Process: No sockets or pipes required.

  • Asynchronous: Fully supports C++20 coroutines.

  • Deterministic: Messages are delivered in order via a shared event loop.

  • Thread-Safe: Uses boost::asio::strand for internal synchronization.

Using create_memory_transport_pair()

To use MemoryTransport, you should always use the helper function mcp::create_memory_transport_pair(). This function creates two connected transport instances where messages written to one are immediately available to the other.

        auto [transport_a, transport_b] = create_memory_transport_pair(io_ctx.get_executor());

Testing Patterns

Integration testing typically follows a “loopback” pattern where both the server and client run in the same process, often within the same io_context. This approach is highly efficient because it eliminates the latency of external networks or process communication while still exercising the full MCP protocol logic.

Loopback Test Pattern

A typical loopback test follows these steps:

  1. Create an asio::io_context to manage the event loop.

  2. Create a MemoryTransport pair using mcp::create_memory_transport_pair().

  3. Initialize a mcp::Server with the desired tools, resources, and capabilities.

  4. Initialize a mcp::Client using one half of the transport pair.

  5. Spawn a coroutine using asio::co_spawn to run the server’s event loop in the background.

  6. Spawn a separate coroutine to execute client requests, using co_await to wait for responses, and then use your testing framework (like GoogleTest) to verify the results.

  7. Run the io_context until all work is complete or a timeout occurs.

Here is a simplified example of this pattern from our integration test suite:

TEST_F(E2EIntegrationTest, InitializeAndPingSession) {
    bool test_passed = false;

    boost::asio::co_spawn(
        io_ctx_,
        [&]() -> mcp::Task<void> {
            // Run server loop in background
            boost::asio::co_spawn(
                io_ctx_,
                [&]() -> mcp::Task<void> {
                    try {
                        co_await server_->run(server_transport_, io_ctx_.get_executor());
                    } catch (...) {
                    }
                },
                boost::asio::detached);

            // Initialize client
            auto init_res = co_await client_->connect(client_info_, client_caps_);
            EXPECT_EQ(init_res.serverInfo.name, "test-server");

            // Ping the server
            co_await client_->ping();

            co_await client_->send_request("shutdown", std::nullopt);
            client_->close();

            test_passed = true;
        },
        boost::asio::detached);

    io_ctx_.run();
    EXPECT_TRUE(test_passed);
}

Writing Scripted Tests

Scripted tests are a way to verify a complex sequence of interactions between a client and a server. Since the SDK is built on coroutines, you can write tests that look like synchronous code but execute asynchronously. This makes the tests easy to read and maintain.

When writing scripted tests, consider the following:

  • State Management: Ensure that your server is fully initialized before the client starts sending requests. A common pattern is to include a small delay or use a signal to indicate server readiness.

  • Concurrent Operations: You can test how the server handles multiple simultaneous requests by spawning multiple client coroutines.

  • Protocol Violations: You can intentionally send malformed messages to verify that your server responds with the appropriate MCP error codes.

boost::asio::co_spawn(io_ctx, [&]() -> mcp::Task<void> {
    // 1. Connect and verify version handshake
    auto init_res = co_await client->connect(client_info, client_caps);
    EXPECT_EQ(init_res.serverInfo.version, "1.0");

    // 2. Call a tool and verify the content.
    // Build the arguments into a named variable: an initializer list inside
    // a co_await expression crashes GCC 12 and 13. See "Compiler Notes" in
    // the README.
    nlohmann::json echo_args{{"message", "test"}};
    auto result = co_await client->call_tool("echo", echo_args);

    // CallToolResult::content holds ContentBlock, a variant, not JSON.
    const auto* text = std::get_if<mcp::TextContent>(&result.content.at(0));
    // ASSERT_* leaves the enclosing function with a plain `return`, which a
    // coroutine cannot use, so check with EXPECT_* and guard the dereference.
    EXPECT_NE(text, nullptr);
    if (text != nullptr) {
        EXPECT_EQ(text->text, "test");
    }

    // 3. Verify server-side state if accessible
    EXPECT_TRUE(server->has_tool("echo"));

    // 4. Gracefully close the connection
    client->close();
}, boost::asio::detached);

E2E Testing Patterns

For full E2E tests, you can use real transports like mcp::StdioTransport or mcp::HttpClientTransport. These tests are essential for verifying that your server correctly handles serialization, platform-specific I/O, and real-world networking conditions.

Key considerations for E2E tests:

  • Environment Setup: Ensure that any external servers or tools your MCP server relies on are available and in a known state.

  • Cleanup: Use teardown logic to kill child processes or close open sockets to avoid leaking resources between test runs.

  • Port Management: When testing network transports, use ephemeral ports to avoid collisions in parallel CI environments.

When running E2E tests in CI, prefer MemoryTransport for most logic verification, and reserve real I/O tests for a smaller set of smoke tests to keep the test suite fast.

Mocking and Stubbing

Because the SDK uses interfaces for transports, you can easily mock mcp::ITransport to simulate network errors, timeouts, or malformed protocol messages. This allows you to test edge cases that are difficult to trigger with the standard transports.

For tool handlers, you can pass mock dependencies into your lambda captures or function objects to verify that tools interact with your business logic correctly. This is particularly useful for testing tools that interact with databases, external APIs, or the filesystem.

CI Integration

MCP tests can be integrated into any standard C++ CI pipeline. Since the SDK relies on Boost.Asio and C++20, ensure your CI environment has a compatible compiler (GCC 11+, Clang 13+, or MSVC 2022+) and the necessary libraries installed.

We recommend running tests under sanitizers (ASan, UBSan) to catch memory leaks or undefined behavior in your asynchronous code.

Example GitHub Action snippet:

- name: Build and Test
  run: |
    python scripts/build.py --test --sanitize
    cd build/release && ctest --output-on-failure

Best Practices

  • Use Strands: When sharing objects between coroutines or interacting with shared state, use boost::asio::strand to prevent race conditions and ensure thread safety.

  • Timeout your Tests: Always use a timeout (e.g., boost::asio::steady_timer) in your test io_context. This prevents your CI pipeline from hanging indefinitely if a coroutine fails to complete.

  • Verify Error Paths: Don’t just test the happy path. Use MemoryTransport to verify how your client handles server-side exceptions, invalid tool arguments, or resource not found errors.

  • Keep Tests Small and Focused: Each integration test should focus on a single feature, tool, or specific interaction flow. This makes it easier to identify the cause of a failure.

  • Clean Up Resources: Ensure io_context::stop() is called or all pending work is finished so your test binary exits cleanly.

  • Log Failures: Use the Context::log_* methods to capture server-side state during test runs, which can be invaluable when debugging failures in headless CI environments.