Skip to content

Commit b9d2e28

Browse files
authored
Merge branch 'main' into feat/259/assign-reviewers
2 parents 0e51a09 + a7d741c commit b9d2e28

File tree

4 files changed

+174
-58
lines changed

4 files changed

+174
-58
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ automation and interaction capabilities for developers and tools.
1515
## Prerequisites
1616

1717
1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed.
18-
2. Once Docker is installed, you will also need to ensure Docker is running.
18+
2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`.
1919
3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new).
2020
The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)).
2121

cmd/github-mcp-server/main.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,15 @@ var (
4545
stdlog.Fatal("Failed to initialize logger:", err)
4646
}
4747

48-
enabledToolsets := viper.GetStringSlice("toolsets")
48+
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
49+
// it's because viper doesn't handle comma-separated values correctly for env
50+
// vars when using GetStringSlice.
51+
// https://github.com/spf13/viper/issues/380
52+
var enabledToolsets []string
53+
err = viper.UnmarshalKey("toolsets", &enabledToolsets)
54+
if err != nil {
55+
stdlog.Fatal("Failed to unmarshal toolsets:", err)
56+
}
4957

5058
logCommands := viper.GetBool("enable-command-logging")
5159
cfg := runConfig{

e2e/e2e_test.go

Lines changed: 163 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ package e2e_test
55
import (
66
"context"
77
"encoding/json"
8+
"fmt"
89
"os"
910
"os/exec"
11+
"slices"
12+
"sync"
1013
"testing"
1114
"time"
1215

@@ -16,85 +19,190 @@ import (
1619
"github.com/stretchr/testify/require"
1720
)
1821

19-
func TestE2E(t *testing.T) {
20-
e2eServerToken := os.Getenv("GITHUB_MCP_SERVER_E2E_TOKEN")
21-
if e2eServerToken == "" {
22-
t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set")
22+
var (
23+
// Shared variables and sync.Once instances to ensure one-time execution
24+
getTokenOnce sync.Once
25+
token string
26+
27+
buildOnce sync.Once
28+
buildError error
29+
)
30+
31+
// getE2EToken ensures the environment variable is checked only once and returns the token
32+
func getE2EToken(t *testing.T) string {
33+
getTokenOnce.Do(func() {
34+
token = os.Getenv("GITHUB_MCP_SERVER_E2E_TOKEN")
35+
if token == "" {
36+
t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set")
37+
}
38+
})
39+
return token
40+
}
41+
42+
// ensureDockerImageBuilt makes sure the Docker image is built only once across all tests
43+
func ensureDockerImageBuilt(t *testing.T) {
44+
buildOnce.Do(func() {
45+
t.Log("Building Docker image for e2e tests...")
46+
cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", ".")
47+
cmd.Dir = ".." // Run this in the context of the root, where the Dockerfile is located.
48+
output, err := cmd.CombinedOutput()
49+
buildError = err
50+
if err != nil {
51+
t.Logf("Docker build output: %s", string(output))
52+
}
53+
})
54+
55+
// Check if the build was successful
56+
require.NoError(t, buildError, "expected to build Docker image successfully")
57+
}
58+
59+
// ClientOpts holds configuration options for the MCP client setup
60+
type ClientOpts struct {
61+
// Environment variables to set before starting the client
62+
EnvVars map[string]string
63+
}
64+
65+
// ClientOption defines a function type for configuring ClientOpts
66+
type ClientOption func(*ClientOpts)
67+
68+
// WithEnvVars returns an option that adds environment variables to the client options
69+
func WithEnvVars(envVars map[string]string) ClientOption {
70+
return func(opts *ClientOpts) {
71+
opts.EnvVars = envVars
72+
}
73+
}
74+
75+
// setupMCPClient sets up the test environment and returns an initialized MCP client
76+
// It handles token retrieval, Docker image building, and applying the provided options
77+
func setupMCPClient(t *testing.T, options ...ClientOption) *mcpClient.Client {
78+
// Get token and ensure Docker image is built
79+
token := getE2EToken(t)
80+
ensureDockerImageBuilt(t)
81+
82+
// Create and configure options
83+
opts := &ClientOpts{
84+
EnvVars: make(map[string]string),
2385
}
2486

25-
// Build the Docker image for the MCP server.
26-
buildDockerImage(t)
87+
// Apply all options to configure the opts struct
88+
for _, option := range options {
89+
option(opts)
90+
}
2791

28-
t.Setenv("GITHUB_PERSONAL_ACCESS_TOKEN", e2eServerToken) // The MCP Client merges the existing environment.
92+
// Prepare Docker arguments
2993
args := []string{
3094
"docker",
3195
"run",
3296
"-i",
3397
"--rm",
3498
"-e",
35-
"GITHUB_PERSONAL_ACCESS_TOKEN",
36-
"github/e2e-github-mcp-server",
99+
"GITHUB_PERSONAL_ACCESS_TOKEN", // Personal access token is all required
100+
}
101+
102+
// Add all environment variables to the Docker arguments
103+
for key := range opts.EnvVars {
104+
args = append(args, "-e", key)
105+
}
106+
107+
// Add the image name
108+
args = append(args, "github/e2e-github-mcp-server")
109+
110+
// Construct the env vars for the MCP Client to execute docker with
111+
dockerEnvVars := make([]string, 0, len(opts.EnvVars)+1)
112+
dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", token))
113+
for key, value := range opts.EnvVars {
114+
dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("%s=%s", key, value))
37115
}
116+
117+
// Create the client
38118
t.Log("Starting Stdio MCP client...")
39-
client, err := mcpClient.NewStdioMCPClient(args[0], []string{}, args[1:]...)
119+
client, err := mcpClient.NewStdioMCPClient(args[0], dockerEnvVars, args[1:]...)
40120
require.NoError(t, err, "expected to create client successfully")
121+
t.Cleanup(func() {
122+
require.NoError(t, client.Close(), "expected to close client successfully")
123+
})
41124

42-
t.Run("Initialize", func(t *testing.T) {
43-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
44-
defer cancel()
125+
// Initialize the client
126+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
127+
defer cancel()
45128

46-
request := mcp.InitializeRequest{}
47-
request.Params.ProtocolVersion = "2025-03-26"
48-
request.Params.ClientInfo = mcp.Implementation{
49-
Name: "e2e-test-client",
50-
Version: "0.0.1",
51-
}
129+
request := mcp.InitializeRequest{}
130+
request.Params.ProtocolVersion = "2025-03-26"
131+
request.Params.ClientInfo = mcp.Implementation{
132+
Name: "e2e-test-client",
133+
Version: "0.0.1",
134+
}
52135

53-
result, err := client.Initialize(ctx, request)
54-
require.NoError(t, err, "expected to initialize successfully")
136+
result, err := client.Initialize(ctx, request)
137+
require.NoError(t, err, "failed to initialize client")
138+
require.Equal(t, "github-mcp-server", result.ServerInfo.Name, "unexpected server name")
55139

56-
require.Equal(t, "github-mcp-server", result.ServerInfo.Name)
57-
})
140+
return client
141+
}
58142

59-
t.Run("CallTool get_me", func(t *testing.T) {
60-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
61-
defer cancel()
143+
func TestGetMe(t *testing.T) {
144+
t.Parallel()
62145

63-
// When we call the "get_me" tool
64-
request := mcp.CallToolRequest{}
65-
request.Params.Name = "get_me"
146+
mcpClient := setupMCPClient(t)
66147

67-
response, err := client.CallTool(ctx, request)
68-
require.NoError(t, err, "expected to call 'get_me' tool successfully")
148+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
149+
defer cancel()
69150

70-
require.False(t, response.IsError, "expected result not to be an error")
71-
require.Len(t, response.Content, 1, "expected content to have one item")
151+
// When we call the "get_me" tool
152+
request := mcp.CallToolRequest{}
153+
request.Params.Name = "get_me"
72154

73-
textContent, ok := response.Content[0].(mcp.TextContent)
74-
require.True(t, ok, "expected content to be of type TextContent")
155+
response, err := mcpClient.CallTool(ctx, request)
156+
require.NoError(t, err, "expected to call 'get_me' tool successfully")
75157

76-
var trimmedContent struct {
77-
Login string `json:"login"`
78-
}
79-
err = json.Unmarshal([]byte(textContent.Text), &trimmedContent)
80-
require.NoError(t, err, "expected to unmarshal text content successfully")
81-
82-
// Then the login in the response should match the login obtained via the same
83-
// token using the GitHub API.
84-
client := github.NewClient(nil).WithAuthToken(e2eServerToken)
85-
user, _, err := client.Users.Get(context.Background(), "")
86-
require.NoError(t, err, "expected to get user successfully")
87-
require.Equal(t, trimmedContent.Login, *user.Login, "expected login to match")
88-
})
158+
require.False(t, response.IsError, "expected result not to be an error")
159+
require.Len(t, response.Content, 1, "expected content to have one item")
160+
161+
textContent, ok := response.Content[0].(mcp.TextContent)
162+
require.True(t, ok, "expected content to be of type TextContent")
163+
164+
var trimmedContent struct {
165+
Login string `json:"login"`
166+
}
167+
err = json.Unmarshal([]byte(textContent.Text), &trimmedContent)
168+
require.NoError(t, err, "expected to unmarshal text content successfully")
169+
170+
// Then the login in the response should match the login obtained via the same
171+
// token using the GitHub API.
172+
ghClient := github.NewClient(nil).WithAuthToken(getE2EToken(t))
173+
user, _, err := ghClient.Users.Get(context.Background(), "")
174+
require.NoError(t, err, "expected to get user successfully")
175+
require.Equal(t, trimmedContent.Login, *user.Login, "expected login to match")
89176

90-
require.NoError(t, client.Close(), "expected to close client successfully")
91177
}
92178

93-
func buildDockerImage(t *testing.T) {
94-
t.Log("Building Docker image for e2e tests...")
179+
func TestToolsets(t *testing.T) {
180+
t.Parallel()
181+
182+
mcpClient := setupMCPClient(
183+
t,
184+
WithEnvVars(map[string]string{
185+
"GITHUB_TOOLSETS": "repos,issues",
186+
}),
187+
)
188+
189+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
190+
defer cancel()
191+
192+
request := mcp.ListToolsRequest{}
193+
response, err := mcpClient.ListTools(ctx, request)
194+
require.NoError(t, err, "expected to list tools successfully")
195+
196+
// We could enumerate the tools here, but we'll need to expose that information
197+
// declaratively in the MCP server, so for the moment let's just check the existence
198+
// of an issue and repo tool, and the non-existence of a pull_request tool.
199+
var toolsContains = func(expectedName string) bool {
200+
return slices.ContainsFunc(response.Tools, func(tool mcp.Tool) bool {
201+
return tool.Name == expectedName
202+
})
203+
}
95204

96-
cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", ".")
97-
cmd.Dir = ".." // Run this in the context of the root, where the Dockerfile is located.
98-
output, err := cmd.CombinedOutput()
99-
require.NoError(t, err, "expected to build Docker image successfully, output: %s", string(output))
205+
require.True(t, toolsContains("get_issue"), "expected to find 'get_issue' tool")
206+
require.True(t, toolsContains("list_branches"), "expected to find 'list_branches' tool")
207+
require.False(t, toolsContains("get_pull_request"), "expected not to find 'get_pull_request' tool")
100208
}

pkg/github/repositories.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
228228
// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
229229
func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
230230
return mcp.NewTool("create_or_update_file",
231-
mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository")),
231+
mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update.")),
232232
mcp.WithToolAnnotation(mcp.ToolAnnotation{
233233
Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"),
234234
ReadOnlyHint: false,

0 commit comments

Comments
 (0)