How to Write Integration Tests for Custom MCP Server Tool Handlers

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, 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 within the appropriate toolset (e.g., AllTools or a custom set).

  3. Verify metadata by running TestAllToolsHaveHandlerFunc in 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, 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 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). The following skeleton demonstrates the essential steps for validating a custom tool handler.

// 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:

Running Integration Tests

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


# 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 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 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. 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →