WRITING April 26, 2026 11 min read

Testing and Debugging MCP Servers: Inspector, Integration Tests, and CI Patterns

The MCP SDKs show you how to build servers but say nothing about testing them. After shipping four servers and writing tests for all of them, here are the patterns that actually work.

The MCP SDKs are good at showing you how to build a server. Register a tool, add a handler, serve over stdio. Every tutorial ends the same way: wire it into Claude Desktop and watch it work. None of them tell you how to test it.

I’ve shipped four MCP servers in Go — scry for code intelligence, tome for database schemas, lore for git history, and flume for HTTP traffic inspection. Every one of them needed real tests. Not “run it and see if Claude complains” tests — actual integration tests that run in CI, catch regressions, and verify that tool handlers return what they should. I had to figure out the patterns from scratch because nobody had written them down.

This post covers what I landed on. If you haven’t read the pillar post on MCP or the Go implementation guide, those provide the context this post assumes.

MCP Inspector: your first debugging tool

Before writing automated tests, get comfortable with MCP Inspector. It’s a web UI that connects to any MCP server and lets you interact with it directly — no AI client needed.

Install and run it:

npx @modelcontextprotocol/inspector ./your-server-binary

Inspector connects to your server over stdio, performs the capability handshake, and gives you a UI showing every tool, resource, and prompt your server exposes. You can call tools with arbitrary inputs and see the raw JSON-RPC responses.

What I actually use Inspector for:

  • Verifying tool schemas — the JSON Schema that Inspector shows is exactly what the AI agent sees. If a parameter description is ambiguous or a required field is missing, you’ll spot it here before the agent misuses it.
  • Testing error paths — send malformed input and verify your server returns structured errors, not stack traces.
  • Checking serialization — complex return types (nested objects, arrays of results) sometimes serialize differently than you expect. Inspector shows the raw response.
  • Transport debugging — if a client isn’t connecting, Inspector isolates whether the problem is your server or the client’s configuration.

One tip: Inspector logs the full JSON-RPC traffic. When something breaks in production with Claude, you can replay the exact same request in Inspector to reproduce it without burning API credits.

Writing integration tests in Go

The real testing story is automated tests that run without a browser. The official Go SDK makes this straightforward because your MCPServer and tool handlers are regular Go code — you don’t need to spawn a subprocess or mock a transport.

Testing tool handlers directly

The simplest approach is testing handler functions in isolation. A tool handler is just a function with signature func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error). Test it like any other function:

package server_test

import (
    "context"
    "testing"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

func TestReadFileHandler(t *testing.T) {
    // Create a temporary file for the test
    tmpFile := t.TempDir() + "/test.txt"
    os.WriteFile(tmpFile, []byte("hello world"), 0644)

    req := mcp.CallToolRequest{}
    req.Params.Name = "read_file"
    req.Params.Arguments = map[string]interface{}{
        "path": tmpFile,
    }

    result, err := handleReadFile(context.Background(), req)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if result.IsError {
        t.Fatalf("tool returned error: %v", result.Content)
    }

    text := result.Content[0].(mcp.TextContent).Text
    if text != "hello world" {
        t.Errorf("got %q, want %q", text, "hello world")
    }
}

This tests your business logic without any protocol overhead. Fast, deterministic, easy to debug.

Testing the full protocol flow

Handler tests don’t verify tool discovery, parameter validation, or the capability handshake. For that, use an in-process client that talks to your server without spawning a subprocess:

package server_test

import (
    "context"
    "testing"

    "github.com/modelcontextprotocol/go-sdk/mcp"
    "github.com/modelcontextprotocol/go-sdk/server"
    mcpclient "github.com/modelcontextprotocol/go-sdk/client"
    "github.com/modelcontextprotocol/go-sdk/client/transport"
)

func newTestServer(t *testing.T) *server.MCPServer {
    t.Helper()
    s := server.NewMCPServer(
        "test-server",
        "0.1.0",
        server.WithToolCapabilities(true),
    )

    s.AddTool(
        mcp.NewTool("greet",
            mcp.WithDescription("Greet someone by name"),
            mcp.WithString("name",
                mcp.Required(),
                mcp.Description("The person's name"),
            ),
        ),
        func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
            name, _ := req.Params.Arguments["name"].(string)
            if name == "" {
                return mcp.NewToolResultError("name is required"), nil
            }
            return mcp.NewToolResultText("Hello, " + name), nil
        },
    )

    return s
}

func TestToolDiscovery(t *testing.T) {
    s := newTestServer(t)

    inproc := transport.NewInProcess(s)
    client, err := mcpclient.New(inproc)
    if err != nil {
        t.Fatal(err)
    }
    defer client.Close()

    ctx := context.Background()
    if err := client.Initialize(ctx); err != nil {
        t.Fatal(err)
    }

    tools, err := client.ListTools(ctx)
    if err != nil {
        t.Fatal(err)
    }

    if len(tools.Tools) != 1 {
        t.Fatalf("expected 1 tool, got %d", len(tools.Tools))
    }

    if tools.Tools[0].Name != "greet" {
        t.Errorf("expected tool name 'greet', got %q", tools.Tools[0].Name)
    }
}

func TestToolExecution(t *testing.T) {
    s := newTestServer(t)

    inproc := transport.NewInProcess(s)
    client, err := mcpclient.New(inproc)
    if err != nil {
        t.Fatal(err)
    }
    defer client.Close()

    ctx := context.Background()
    client.Initialize(ctx)

    result, err := client.CallTool(ctx, "greet", map[string]interface{}{
        "name": "World",
    })
    if err != nil {
        t.Fatal(err)
    }

    if result.IsError {
        t.Fatalf("tool returned error: %v", result.Content)
    }

    text := result.Content[0].(mcp.TextContent).Text
    if text != "Hello, World" {
        t.Errorf("got %q, want %q", text, "Hello, World")
    }
}

func TestToolErrorHandling(t *testing.T) {
    s := newTestServer(t)

    inproc := transport.NewInProcess(s)
    client, err := mcpclient.New(inproc)
    if err != nil {
        t.Fatal(err)
    }
    defer client.Close()

    ctx := context.Background()
    client.Initialize(ctx)

    // Missing required parameter
    result, err := client.CallTool(ctx, "greet", map[string]interface{}{})
    if err != nil {
        t.Fatal(err)
    }

    if !result.IsError {
        t.Error("expected error result for missing parameter")
    }
}

func TestNonexistentTool(t *testing.T) {
    s := newTestServer(t)

    inproc := transport.NewInProcess(s)
    client, err := mcpclient.New(inproc)
    if err != nil {
        t.Fatal(err)
    }
    defer client.Close()

    ctx := context.Background()
    client.Initialize(ctx)

    _, err = client.CallTool(ctx, "nonexistent", map[string]interface{}{})
    if err == nil {
        t.Error("expected error for nonexistent tool")
    }
}

This pattern gives you full protocol coverage: initialization, tool discovery, execution, and error handling. The in-process transport means no subprocesses, no ports, no flakiness from network timing.

Testing transport layers

The tests above use the in-process transport, which is great for logic testing. But if your server runs over HTTP in production, you need to test that too.

Testing stdio servers

For stdio, you can test by spawning your binary as a subprocess and writing JSON-RPC messages to its stdin:

func TestStdioTransport(t *testing.T) {
    cmd := exec.Command("go", "run", ".")
    stdin, _ := cmd.StdinPipe()
    stdout, _ := cmd.StdoutPipe()
    cmd.Start()
    defer cmd.Process.Kill()

    // Send initialize request
    initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}` + "\n"
    stdin.Write([]byte(initReq))

    // Read response
    scanner := bufio.NewScanner(stdout)
    scanner.Scan()
    var resp map[string]interface{}
    json.Unmarshal(scanner.Bytes(), &resp)

    if resp["error"] != nil {
        t.Fatalf("initialize failed: %v", resp["error"])
    }
}

This is a true end-to-end test. It validates that your binary starts, reads stdio correctly, and responds to the MCP handshake. I use one or two of these as smoke tests, then rely on in-process tests for the bulk of coverage.

Testing HTTP servers

For the streamable HTTP transport, use net/http/httptest:

func TestHTTPTransport(t *testing.T) {
    s := newTestServer(t)
    handler := server.NewStreamableHTTPHandler(s)

    ts := httptest.NewServer(handler)
    defer ts.Close()

    // Connect a client to the test server
    httpTransport, err := transport.NewStreamableHTTP(ts.URL)
    if err != nil {
        t.Fatal(err)
    }

    client, err := mcpclient.New(httpTransport)
    if err != nil {
        t.Fatal(err)
    }
    defer client.Close()

    ctx := context.Background()
    if err := client.Initialize(ctx); err != nil {
        t.Fatal(err)
    }

    tools, err := client.ListTools(ctx)
    if err != nil {
        t.Fatal(err)
    }

    if len(tools.Tools) == 0 {
        t.Error("expected at least one tool")
    }
}

This tests the real HTTP path — session management, SSE streaming, content-type negotiation — without needing to bind a fixed port or manage process lifecycle.

CI patterns

MCP server tests are just Go tests. They don’t need external services, databases, or running AI clients. A minimal GitHub Actions workflow:

name: test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - run: go test ./... -race -timeout 60s

That’s genuinely it. The in-process transport means no Docker containers, no port binding, no network dependencies. Tests run in under 10 seconds for a typical MCP server.

A few things I’ve added to my CI over time:

  • -race flag — MCP servers handle concurrent requests. Race detection catches shared-state bugs that only manifest under load.
  • Build verificationgo build -o /dev/null ./... catches compilation issues in handlers that tests might not exercise.
  • Schema validation — a test that lists all tools and validates their JSON schemas are well-formed. This catches broken parameter definitions before they confuse an agent.
func TestAllToolSchemasValid(t *testing.T) {
    s := newProductionServer()
    inproc := transport.NewInProcess(s)
    client, _ := mcpclient.New(inproc)
    defer client.Close()

    ctx := context.Background()
    client.Initialize(ctx)

    tools, _ := client.ListTools(ctx)
    for _, tool := range tools.Tools {
        if tool.Description == "" {
            t.Errorf("tool %q has empty description", tool.Name)
        }
        if tool.InputSchema.Type != "object" {
            t.Errorf("tool %q input schema is not an object", tool.Name)
        }
    }
}

Debugging production issues

Tests pass, Inspector looks good, but the agent still does the wrong thing. This is the category of problem that takes the most time in practice.

Tool descriptions that confuse the agent

The most common issue I’ve hit across all four servers is the agent misusing a tool because the description was ambiguous. In scry, I had a find_symbol tool that the agent would call with full file paths instead of symbol names because the description said “find a symbol in the codebase” — which the agent interpreted as “find something in the codebase.”

The fix is always the same: make the description and parameter descriptions absurdly specific. Say what the tool does, what it doesn’t do, and what format the parameters expect. The agent reads these descriptions literally.

// Bad: agent will misuse this
mcp.WithDescription("Search for code")

// Good: no ambiguity
mcp.WithDescription("Find Go symbol definitions (functions, types, methods) by name. Returns file path, line number, and signature. Does NOT search file contents — use grep for that.")

Parameter schema mismatches

The second most common issue: the agent sends a parameter as the wrong type. JSON doesn’t distinguish between "42" and 42, but your handler might. If you define a parameter as a string but the agent sends a number, req.Params.Arguments["port"].(string) will panic.

Always use type assertions with the ok pattern, and handle the wrong-type case:

port, ok := req.Params.Arguments["port"].(float64) // JSON numbers are float64
if !ok {
    // Maybe the agent sent it as a string
    portStr, ok := req.Params.Arguments["port"].(string)
    if !ok {
        return mcp.NewToolResultError("port must be a number"), nil
    }
    port, _ = strconv.ParseFloat(portStr, 64)
}

Transport timeouts

Long-running tools hit transport timeouts, especially over stdio where the client has a read deadline. In lore, git blame on large files could take 30+ seconds, which exceeded some clients’ default timeouts.

The options are:

  1. Make the tool faster (obvious but not always possible)
  2. Return partial results with a continuation token
  3. Switch to HTTP transport where you have more control over timeouts

I ended up doing option 1 for lore — caching blame results in BadgerDB so repeat queries are instant. But for genuinely long operations, option 2 is the right pattern: return what you have, tell the agent there’s more, and let it call again.

Stderr is your friend

For stdio servers, anything you write to stderr is invisible to the protocol but visible in the client’s logs. Use it liberally during development:

fmt.Fprintf(os.Stderr, "[DEBUG] tool=%s args=%v\n", req.Params.Name, req.Params.Arguments)

In flume, I pipe stderr to a file and tail it while testing. It’s the simplest debugging technique and it works when nothing else does.

The testing pyramid for MCP servers

After four servers, the pattern I’ve settled on is:

  1. Unit tests on handlers — fast, cover business logic and edge cases. The bulk of your tests.
  2. In-process integration tests — verify tool discovery, protocol flow, and error handling. A handful per server.
  3. One stdio/HTTP smoke test — verify the binary actually starts and speaks the protocol. Catches build configuration issues.
  4. Inspector sessions — manual but essential for verifying tool descriptions read well from the agent’s perspective.

The test code I showed in this post is the same structure I use in my production servers. It’s not complicated — the hard part was figuring out that this is the right approach, not finding fancy testing frameworks. MCP servers are simple programs. Test them simply.

Frequently Asked Questions

How do I test an MCP server?

Use MCP Inspector for interactive debugging during development, then write integration tests that spin up your server in-process and send JSON-RPC requests directly to tool handlers. The official Go SDK lets you call tool handlers as normal functions in tests, or you can use the in-process client to test the full protocol flow including capability negotiation.

What is MCP Inspector and how do I use it?

MCP Inspector is a web-based debugging tool that connects to any MCP server over stdio or HTTP, shows you the capability negotiation, lists all tools/resources/prompts, and lets you call them interactively. Install it with npx @modelcontextprotocol/inspector, point it at your server binary, and you get a UI for testing tools without needing an AI client.

How do I debug an MCP server that isn't working with Claude?

Start with MCP Inspector to verify the server works in isolation. Check that tool descriptions are clear and unambiguous, parameter schemas match what the agent sends, and error responses use proper MCP error types. Common issues include tool descriptions that confuse the agent, parameter type mismatches, and transport timeouts on long-running operations.

Can I run MCP server tests in CI?

Yes. MCP server integration tests run like any other Go test since they don't need external services. In GitHub Actions, use a standard Go workflow with go test ./... and the tests will spin up servers in-process. No Docker, no network ports, no special infrastructure required for stdio-based servers.

How do I test MCP server HTTP transport?

Use net/http/httptest to start your server's HTTP handler on a random port, then connect an MCP client to that address. This tests the full HTTP transport including SSE streaming, session management, and connection lifecycle without needing to bind real ports or manage processes.

Back to all writing