diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..21b65bfa --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,84 @@ +# End To End (e2e) Tests + +The purpose of the E2E tests is to have a simple (currently) test that gives maintainers some confidence in the black box behavior of our artifacts. It does this by: + * Building the `github-mcp-server` docker image + * Running the image + * Interacting with the server via stdio + * Issuing requests that interact with the live GitHub API + +## Running the Tests + +A service must be running that supports image building and container creation via the `docker` CLI. + +Since these tests require a token to interact with real resources on the GitHub API, it is gated behind the `e2e` build flag. + +``` +GITHUB_MCP_SERVER_E2E_TOKEN= go test -v --tags e2e ./e2e +``` + +The `GITHUB_MCP_SERVER_E2E_TOKEN` environment variable is mapped to `GITHUB_PERSONAL_ACCESS_TOKEN` internally, but separated to avoid accidental reuse of credentials. + +## Example + +The following diff adjusts the `get_me` tool to return `foobar` as the user login. + +```diff +diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go +index 1c91d70..ac4ef2b 100644 +--- a/pkg/github/context_tools.go ++++ b/pkg/github/context_tools.go +@@ -39,6 +39,8 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc + return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil + } + ++ user.Login = sPtr("foobar") ++ + r, err := json.Marshal(user) + if err != nil { + return nil, fmt.Errorf("failed to marshal user: %w", err) +@@ -47,3 +49,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc + return mcp.NewToolResultText(string(r)), nil + } + } ++ ++func sPtr(s string) *string { ++ return &s ++} +``` + +Running the tests: + +``` +➜ GITHUB_MCP_SERVER_E2E_TOKEN=$(gh auth token) go test -v --tags e2e ./e2e +=== RUN TestE2E + e2e_test.go:92: Building Docker image for e2e tests... + e2e_test.go:36: Starting Stdio MCP client... +=== RUN TestE2E/Initialize +=== RUN TestE2E/CallTool_get_me + e2e_test.go:85: + Error Trace: /Users/williammartin/workspace/github-mcp-server/e2e/e2e_test.go:85 + Error: Not equal: + expected: "foobar" + actual : "williammartin" + + Diff: + --- Expected + +++ Actual + @@ -1 +1 @@ + -foobar + +williammartin + Test: TestE2E/CallTool_get_me + Messages: expected login to match +--- FAIL: TestE2E (1.05s) + --- PASS: TestE2E/Initialize (0.09s) + --- FAIL: TestE2E/CallTool_get_me (0.46s) +FAIL +FAIL github.com/github/github-mcp-server/e2e 1.433s +FAIL +``` + +## Limitations + +The current test suite is intentionally very limited in scope. This is because the maintenance costs on e2e tests tend to increase significantly over time. To read about some challenges with GitHub integration tests, see [go-github integration tests README](https://github.com/google/go-github/blob/5b75aa86dba5cf4af2923afa0938774f37fa0a67/test/README.md). We will expand this suite circumspectly! + +Currently, visibility into failures is not particularly good. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 00000000..3d8c45dc --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,100 @@ +//go:build e2e + +package e2e_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "testing" + "time" + + "github.com/google/go-github/v69/github" + mcpClient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestE2E(t *testing.T) { + e2eServerToken := os.Getenv("GITHUB_MCP_SERVER_E2E_TOKEN") + if e2eServerToken == "" { + t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set") + } + + // Build the Docker image for the MCP server. + buildDockerImage(t) + + t.Setenv("GITHUB_PERSONAL_ACCESS_TOKEN", e2eServerToken) // The MCP Client merges the existing environment. + args := []string{ + "docker", + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "github/e2e-github-mcp-server", + } + t.Log("Starting Stdio MCP client...") + client, err := mcpClient.NewStdioMCPClient(args[0], []string{}, args[1:]...) + require.NoError(t, err, "expected to create client successfully") + + t.Run("Initialize", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + request := mcp.InitializeRequest{} + request.Params.ProtocolVersion = "2025-03-26" + request.Params.ClientInfo = mcp.Implementation{ + Name: "e2e-test-client", + Version: "0.0.1", + } + + result, err := client.Initialize(ctx, request) + require.NoError(t, err, "expected to initialize successfully") + + require.Equal(t, "github-mcp-server", result.ServerInfo.Name) + }) + + t.Run("CallTool get_me", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // When we call the "get_me" tool + request := mcp.CallToolRequest{} + request.Params.Name = "get_me" + + response, err := client.CallTool(ctx, request) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + + require.False(t, response.IsError, "expected result not to be an error") + require.Len(t, response.Content, 1, "expected content to have one item") + + textContent, ok := response.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedContent struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedContent) + require.NoError(t, err, "expected to unmarshal text content successfully") + + // Then the login in the response should match the login obtained via the same + // token using the GitHub API. + client := github.NewClient(nil).WithAuthToken(e2eServerToken) + user, _, err := client.Users.Get(context.Background(), "") + require.NoError(t, err, "expected to get user successfully") + require.Equal(t, trimmedContent.Login, *user.Login, "expected login to match") + }) + + require.NoError(t, client.Close(), "expected to close client successfully") +} + +func buildDockerImage(t *testing.T) { + t.Log("Building Docker image for e2e tests...") + + cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", ".") + cmd.Dir = ".." // Run this in the context of the root, where the Dockerfile is located. + output, err := cmd.CombinedOutput() + require.NoError(t, err, "expected to build Docker image successfully, output: %s", string(output)) +}