# How to Write Integration Tests for Custom MCP Server Tool Handlers

> Learn how to write integration tests for custom MCP server tool handlers by booting a real server and invoking tools through the MCP protocol to test GitHub API functionality.

- Repository: [GitHub/github-mcp-server](https://github.com/github/github-mcp-server)
- Tags: how-to-guide
- Published: 2026-02-16

---

**Integration tests for custom MCP server tool handlers require booting a real server instance—either in-process or via Docker—and invoking tools through the MCP protocol to verify end-to-end functionality against the GitHub API.**

Writing robust **integration tests for custom MCP server tool handlers** ensures your GitHub MCP Server extensions work correctly when wired through the full protocol stack, feature-flag middleware, and request filtering. This guide demonstrates how to validate custom tool handlers using the testing infrastructure found in the `github/github-mcp-server` repository, covering both in-process and Docker-based validation strategies.

## Understanding the Testing Architecture

The repository provides two distinct testing approaches for tool handlers. **Unit tests** live in `pkg/github/*_test.go` and use `MockHTTPClientWithHandlers` to validate handler logic in isolation. **Integration tests** reside in the `e2e/` directory and exercise the complete server stack.

According to the source code in [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go), the integration harness provides `setupMCPClient`, a helper that boots the server via `ghmcp.NewMCPServer` and returns a connected `*mcp.ClientSession`【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L24-L42】. This client can invoke any registered tool through the MCP protocol, making it ideal for end-to-end validation.

## Preparing Your Custom Tool

Before writing integration tests, ensure your tool is properly implemented and registered.

1. **Define the tool** in `pkg/github/<tool>.go` using `NewTool` or `NewDynamicTool`. The handler must accept `ToolDependencies` and return a typed `mcp.ToolHandlerFor[In, Out]`.

2. **Register the tool** in [`pkg/github/tools.go`](https://github.com/github/github-mcp-server/blob/main/pkg/github/tools.go) within the appropriate toolset (e.g., `AllTools` or a custom set).

3. **Verify metadata** by running `TestAllToolsHaveHandlerFunc` in [`pkg/github/tools_validation_test.go`](https://github.com/github/github-mcp-server/blob/main/pkg/github/tools_validation_test.go) to ensure no duplicate names exist and all required fields are populated.

## Choosing Your Integration Test Strategy

The `e2e` package supports two execution modes, selectable based on your CI requirements and debugging needs.

### In-Process Server

The **in-process** strategy creates the server directly via `ghmcp.NewMCPServer` and connects via in-memory transports. This approach requires no Docker daemon and executes faster, making it ideal for CI pipelines.

As implemented in [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go), the `setupMCPClient` helper automatically selects in-process mode when Docker is unavailable or when specific environment variables are unset【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L24-L42】.

### Docker-Based Server

The **Docker-based** strategy builds the server image via `ensureDockerImageBuilt` and launches it with `CommandTransport`, exactly matching the end-user experience. This validates the full binary build, environment variable injection, and stdio transport.

To enable this mode, ensure `GITHUB_MCP_SERVER_E2E_DEBUG` is unset and Docker is available. The helper `setupMCPClient` in [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go) handles the container lifecycle automatically【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L70-L81】.

## Writing the Integration Test

Create a new file under `e2e/` (e.g., [`e2e/milestones_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/milestones_test.go)). The following skeleton demonstrates the essential steps for validating a custom tool handler.

```go
// e2e/milestones_test.go
package e2e_test

import (
	"context"
	"encoding/json"
	"fmt"
	"testing"

	"github.com/github/github-mcp-server/internal/ghmcp"
	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/require"
)

func TestListMilestones(t *testing.T) {
	t.Parallel()

	// 1. Initialize the MCP client using the shared harness
	mcpClient := setupMCPClient(t)
	ctx := context.Background()

	// 2. Verify authentication and retrieve the current user
	resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{Name: "get_me"})
	require.NoError(t, err)
	require.False(t, resp.IsError)

	var me struct{ Login string `json:"login"` }
	err = json.Unmarshal([]byte(resp.Content[0].(*mcp.TextContent).Text), &me)
	require.NoError(t, err)

	// 3. Invoke the custom tool handler
	resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
		Name: "list_milestones",
		Arguments: map[string]any{
			"owner": me.Login,
			"repo":  "github-mcp-server",
		},
	})
	require.NoError(t, err)
	require.False(t, resp.IsError)

	// 4. Validate the response payload
	var milestones []struct {
		Title string `json:"title"`
	}
	err = json.Unmarshal([]byte(resp.Content[0].(*mcp.TextContent).Text), &milestones)
	require.NoError(t, err)
	require.NotEmpty(t, milestones, "expected at least one milestone")
	fmt.Printf("Found %d milestones\n", len(milestones))
}

```

**Key implementation details:**

- **Harness reuse**: The `setupMCPClient` helper from [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go) handles server initialization, transport selection (in-process or Docker), and rate-limit guarding【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L24-L42】.

- **Authentication prerequisite**: Always call `get_me` first to validate the token and obtain the authenticated username, as demonstrated in the existing `TestGetMe` implementation【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L52-L66】.

- **Response handling**: Tool handlers return `*mcp.CallToolResult` containing `TextContent`. Unmarshal the JSON string from the `Text` field to verify structured data, following the pattern in `TestFileDeletion`【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L40-L50】.

## Running Integration Tests

Execute your integration tests using the Go test runner with the `e2e` build tag:

```bash

# Export a valid GitHub token with appropriate scopes

export GITHUB_MCP_SERVER_E2E_TOKEN=$(gh auth token)

# Run a specific integration test

go test -v --tags e2e ./e2e -run TestListMilestones

# Run all integration tests

go test -v --tags e2e ./e2e

```

For **Docker-based validation**, ensure the `GITHUB_MCP_SERVER_E2E_DEBUG` environment variable is unset and Docker is running. The test harness automatically builds the image via `ensureDockerImageBuilt` and launches the container【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L13-L20】.

## Troubleshooting Common Pitfalls

### Rate Limit Exhaustion

**Symptoms**: Tests fail with "API rate limit exceeded" errors.

**Solution**: The `waitForRateLimit` helper in [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go) automatically pauses execution when `core.Remaining < 50`【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L80-L108】. Ensure your test calls `setupMCPClient`, which invokes this guard automatically.

### Tool Not Visible in Dynamic Mode

**Symptoms**: `ListTools` does not return your custom tool.

**Solution**: Register the tool in an enabled toolset. In integration tests, pass the toolset name via the configuration. The `TestToolsets` implementation demonstrates enabling specific toolsets at runtime【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L84-L92】.

### Feature-Flagged Tools Hidden

**Symptoms**: Your tool is gated behind a feature flag but unavailable during testing.

**Solution**: Add the flag name to `EnabledFeatures` in the server configuration. Set the `GITHUB_FEATURES` environment variable or extend the `withToolsets` helper to include feature flags.

### Read-Only Mode Blocking Mutations

**Symptoms**: Write operations return "readonly" errors.

**Solution**: Ensure `ReadOnly: false` in the `MCPServerConfig` (the default). For Docker tests, explicitly set `-e GITHUB_READONLY=false` to override any default read-only configurations.

## Summary

- **Integration tests** validate custom MCP server tool handlers by booting a real server instance and exercising the full protocol stack.
- Use **`setupMCPClient`** from [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go) to automatically handle in-process or Docker-based server initialization.
- Always **authenticate first** using the `get_me` tool to validate tokens and retrieve the current user.
- **Unmarshal** the `TextContent` response to verify structured data returned by your handler.
- Handle **rate limits** automatically via the built-in `waitForRateLimit` helper.
- Enable **toolsets and features** explicitly in test configuration when testing dynamic or flagged tools.

## Frequently Asked Questions

### What is the difference between unit tests and integration tests for MCP tools?

Unit tests reside in `pkg/github/*_test.go` and use `MockHTTPClientWithHandlers` to isolate handler logic without network calls. Integration tests live in `e2e/` and validate the complete stack including server initialization, middleware, routing, and actual GitHub API communication. Use unit tests for rapid feedback during development and integration tests to guarantee end-to-end functionality.

### How do I test a tool that requires specific feature flags?

Set the `GITHUB_FEATURES` environment variable to a comma-separated list of enabled flags before running tests. Alternatively, modify the `MCPServerConfig` passed to `setupMCPClient` to include the specific flag in the `EnabledFeatures` slice. The server will then register the tool and make it available via `CallTool` during your integration test.

### Can I run integration tests without Docker?

Yes. The `setupMCPClient` helper automatically falls back to in-process mode when Docker is unavailable or when `GITHUB_MCP_SERVER_E2E_DEBUG` is set. This mode creates the server directly via `ghmcp.NewMCPServer` and connects via in-memory transports, providing fast execution without container overhead while still exercising the full MCP protocol stack.

### What should I do if my integration test hits GitHub API rate limits?

The test harness includes automatic rate-limit protection via the `waitForRateLimit` function in [`e2e/e2e_test.go`](https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go). When `core.Remaining` drops below 50, the test pauses until the reset window passes. Ensure you use `setupMCPClient` which invokes this guard automatically, or call `waitForRateLimit(t)` explicitly before GitHub API-heavy operations.