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.
-
Define the tool in
pkg/github/<tool>.gousingNewToolorNewDynamicTool. The handler must acceptToolDependenciesand return a typedmcp.ToolHandlerFor[In, Out]. -
Register the tool in
pkg/github/tools.gowithin the appropriate toolset (e.g.,AllToolsor a custom set). -
Verify metadata by running
TestAllToolsHaveHandlerFuncinpkg/github/tools_validation_test.goto 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:
-
Harness reuse: The
setupMCPClienthelper frome2e/e2e_test.gohandles 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_mefirst to validate the token and obtain the authenticated username, as demonstrated in the existingTestGetMeimplementation【source: https://github.com/github/github-mcp-server/blob/main/e2e/e2e_test.go#L52-L66】. -
Response handling: Tool handlers return
*mcp.CallToolResultcontainingTextContent. Unmarshal the JSON string from theTextfield to verify structured data, following the pattern inTestFileDeletion【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:
# 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
setupMCPClientfrome2e/e2e_test.goto automatically handle in-process or Docker-based server initialization. - Always authenticate first using the
get_metool to validate tokens and retrieve the current user. - Unmarshal the
TextContentresponse to verify structured data returned by your handler. - Handle rate limits automatically via the built-in
waitForRateLimithelper. - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →