Skip to content

Commit 8d56fe2

Browse files
feat: partition tools by product/feature
1 parent 01aefd3 commit 8d56fe2

File tree

11 files changed

+1328
-227
lines changed

11 files changed

+1328
-227
lines changed

Diff for: README.md

+73
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,79 @@ If you don't have Docker, you can use `go` to build the binary in the
9696
command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to
9797
your token.
9898

99+
## Tool Configuration
100+
101+
The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools.
102+
103+
### Available Toolsets
104+
105+
The following sets of tools are available:
106+
107+
| Toolset | Description | Default Status |
108+
| ----------------------- | ------------------------------------------------------------- | -------------- |
109+
| `repos` | Repository-related tools (file operations, branches, commits) | Enabled |
110+
| `issues` | Issue-related tools (create, read, update, comment) | Enabled |
111+
| `search` | Search functionality (code, repositories, users) | Enabled |
112+
| `pull_requests` | Pull request operations (create, merge, review) | Enabled |
113+
| `context` | Tools providing context about current user and GitHub context | Enabled |
114+
| `dynamic` | Tool discovery and dynamic enablement of GitHub MCP tools | Enabled |
115+
| `code_security` | Code scanning alerts and security features | Disabled |
116+
| `experiments` | Experimental features (not considered stable) | Disabled |
117+
| `all` | Special flag to enable all features | Disabled |
118+
119+
### Specifying Toolsets
120+
121+
You can enable specific features in two ways:
122+
123+
1. **Using Command Line Argument**:
124+
125+
```bash
126+
github-mcp-server --toolsets repos,issues,pull_requests,code_security
127+
```
128+
129+
2. **Using Environment Variable**:
130+
```bash
131+
GITHUB_TOOLSETS="repos,issues,pull_requests,code_security" ./github-mcp-server
132+
```
133+
134+
The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided.
135+
136+
### Default Enabled Toolsets
137+
138+
By default, the following toolsets are enabled:
139+
140+
- `repos`
141+
- `issues`
142+
- `pull_requests`
143+
- `search`
144+
- `context-_ools`
145+
- `dynamic_tools`
146+
147+
### Using With Docker
148+
149+
When using Docker, you can pass the toolsets as environment variables:
150+
151+
```bash
152+
docker run -i --rm \
153+
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-token> \
154+
-e GITHUB_TOOLSETS="repos,issues,pull_requests,code_security,experiments" \
155+
ghcr.io/github/github-mcp-server
156+
```
157+
158+
### The "everything" Toolset
159+
160+
The special toolset `everything` can be provided to enable all available features regardless of any other toolsets passed:
161+
162+
```bash
163+
./github-mcp-server --toolsets everything
164+
```
165+
166+
Or using the environment variable:
167+
168+
```bash
169+
GITHUB_TOOLSETS="everything" ./github-mcp-server
170+
```
171+
99172
## GitHub Enterprise Server
100173

101174
The flag `--gh-host` and the environment variable `GH_HOST` can be used to set

Diff for: cmd/github-mcp-server/main.go

+29-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
stdlog "log"
88
"os"
99
"os/signal"
10+
"strings"
1011
"syscall"
1112

1213
"github.com/github/github-mcp-server/pkg/github"
@@ -43,12 +44,25 @@ var (
4344
if err != nil {
4445
stdlog.Fatal("Failed to initialize logger:", err)
4546
}
47+
48+
enabledToolsets := viper.GetStringSlice("toolsets")
49+
50+
// Env gets precedence over command line flags
51+
if envToolsets := os.Getenv("GITHUB_TOOLSETS"); envToolsets != "" {
52+
enabledToolsets = []string{}
53+
// Split envFeats by comma, trim whitespace, and add to the slice
54+
for _, toolset := range strings.Split(envToolsets, ",") {
55+
enabledToolsets = append(enabledToolsets, strings.TrimSpace(toolset))
56+
}
57+
}
58+
4659
logCommands := viper.GetBool("enable-command-logging")
4760
cfg := runConfig{
4861
readOnly: readOnly,
4962
logger: logger,
5063
logCommands: logCommands,
5164
exportTranslations: exportTranslations,
65+
enabledToolsets: enabledToolsets,
5266
}
5367
if err := runStdioServer(cfg); err != nil {
5468
stdlog.Fatal("failed to run stdio server:", err)
@@ -61,13 +75,15 @@ func init() {
6175
cobra.OnInitialize(initConfig)
6276

6377
// Add global flags that will be shared by all commands
78+
rootCmd.PersistentFlags().StringSlice("toolsets", github.DefaultTools, "A comma separated list of groups of tools to enable, defaults to issues/repos/search")
6479
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
6580
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
6681
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
6782
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
6883
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
6984

7085
// Bind flag to viper
86+
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
7187
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
7288
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
7389
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
@@ -106,6 +122,7 @@ type runConfig struct {
106122
logger *log.Logger
107123
logCommands bool
108124
exportTranslations bool
125+
enabledToolsets []string
109126
}
110127

111128
func runStdioServer(cfg runConfig) error {
@@ -140,8 +157,18 @@ func runStdioServer(cfg runConfig) error {
140157
getClient := func(_ context.Context) (*gogithub.Client, error) {
141158
return ghClient, nil // closing over client
142159
}
143-
// Create
144-
ghServer := github.NewServer(getClient, version, cfg.readOnly, t)
160+
161+
// Create server
162+
ghServer := github.NewServer(version)
163+
164+
// Create toolsets
165+
toolsets, err := github.InitToolsets(ghServer, cfg.enabledToolsets, cfg.readOnly, getClient, t)
166+
if err != nil {
167+
stdlog.Fatal("Failed to initialize toolsets:", err)
168+
}
169+
// Register the tools with the server
170+
toolsets.RegisterTools(ghServer)
171+
145172
stdioServer := server.NewStdioServer(ghServer)
146173

147174
stdLogger := stdlog.New(cfg.logger.Writer(), "stdioserver", 0)

Diff for: pkg/github/context_tools.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
"github.com/mark3labs/mcp-go/server"
13+
)
14+
15+
// GetMe creates a tool to get details of the authenticated user.
16+
func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
17+
return mcp.NewTool("get_me",
18+
mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request include \"me\", \"my\"...")),
19+
mcp.WithString("reason",
20+
mcp.Description("Optional: reason the session was created"),
21+
),
22+
),
23+
func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
24+
client, err := getClient(ctx)
25+
if err != nil {
26+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
27+
}
28+
user, resp, err := client.Users.Get(ctx, "")
29+
if err != nil {
30+
return nil, fmt.Errorf("failed to get user: %w", err)
31+
}
32+
defer func() { _ = resp.Body.Close() }()
33+
34+
if resp.StatusCode != http.StatusOK {
35+
body, err := io.ReadAll(resp.Body)
36+
if err != nil {
37+
return nil, fmt.Errorf("failed to read response body: %w", err)
38+
}
39+
return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil
40+
}
41+
42+
r, err := json.Marshal(user)
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to marshal user: %w", err)
45+
}
46+
47+
return mcp.NewToolResultText(string(r)), nil
48+
}
49+
}

Diff for: pkg/github/context_tools_test.go

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/google/go-github/v69/github"
12+
"github.com/migueleliasweb/go-github-mock/src/mock"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func Test_GetMe(t *testing.T) {
18+
// Verify tool definition
19+
mockClient := github.NewClient(nil)
20+
tool, _ := GetMe(stubGetClientFn(mockClient), translations.NullTranslationHelper)
21+
22+
assert.Equal(t, "get_me", tool.Name)
23+
assert.NotEmpty(t, tool.Description)
24+
assert.Contains(t, tool.InputSchema.Properties, "reason")
25+
assert.Empty(t, tool.InputSchema.Required) // No required parameters
26+
27+
// Setup mock user response
28+
mockUser := &github.User{
29+
Login: github.Ptr("testuser"),
30+
Name: github.Ptr("Test User"),
31+
Email: github.Ptr("[email protected]"),
32+
Bio: github.Ptr("GitHub user for testing"),
33+
Company: github.Ptr("Test Company"),
34+
Location: github.Ptr("Test Location"),
35+
HTMLURL: github.Ptr("https://github.com/testuser"),
36+
CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
37+
Type: github.Ptr("User"),
38+
Plan: &github.Plan{
39+
Name: github.Ptr("pro"),
40+
},
41+
}
42+
43+
tests := []struct {
44+
name string
45+
mockedClient *http.Client
46+
requestArgs map[string]interface{}
47+
expectError bool
48+
expectedUser *github.User
49+
expectedErrMsg string
50+
}{
51+
{
52+
name: "successful get user",
53+
mockedClient: mock.NewMockedHTTPClient(
54+
mock.WithRequestMatch(
55+
mock.GetUser,
56+
mockUser,
57+
),
58+
),
59+
requestArgs: map[string]interface{}{},
60+
expectError: false,
61+
expectedUser: mockUser,
62+
},
63+
{
64+
name: "successful get user with reason",
65+
mockedClient: mock.NewMockedHTTPClient(
66+
mock.WithRequestMatch(
67+
mock.GetUser,
68+
mockUser,
69+
),
70+
),
71+
requestArgs: map[string]interface{}{
72+
"reason": "Testing API",
73+
},
74+
expectError: false,
75+
expectedUser: mockUser,
76+
},
77+
{
78+
name: "get user fails",
79+
mockedClient: mock.NewMockedHTTPClient(
80+
mock.WithRequestMatchHandler(
81+
mock.GetUser,
82+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
83+
w.WriteHeader(http.StatusUnauthorized)
84+
_, _ = w.Write([]byte(`{"message": "Unauthorized"}`))
85+
}),
86+
),
87+
),
88+
requestArgs: map[string]interface{}{},
89+
expectError: true,
90+
expectedErrMsg: "failed to get user",
91+
},
92+
}
93+
94+
for _, tc := range tests {
95+
t.Run(tc.name, func(t *testing.T) {
96+
// Setup client with mock
97+
client := github.NewClient(tc.mockedClient)
98+
_, handler := GetMe(stubGetClientFn(client), translations.NullTranslationHelper)
99+
100+
// Create call request
101+
request := createMCPRequest(tc.requestArgs)
102+
103+
// Call handler
104+
result, err := handler(context.Background(), request)
105+
106+
// Verify results
107+
if tc.expectError {
108+
require.Error(t, err)
109+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
110+
return
111+
}
112+
113+
require.NoError(t, err)
114+
115+
// Parse result and get text content if no error
116+
textContent := getTextResult(t, result)
117+
118+
// Unmarshal and verify the result
119+
var returnedUser github.User
120+
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
121+
require.NoError(t, err)
122+
123+
// Verify user details
124+
assert.Equal(t, *tc.expectedUser.Login, *returnedUser.Login)
125+
assert.Equal(t, *tc.expectedUser.Name, *returnedUser.Name)
126+
assert.Equal(t, *tc.expectedUser.Email, *returnedUser.Email)
127+
assert.Equal(t, *tc.expectedUser.Bio, *returnedUser.Bio)
128+
assert.Equal(t, *tc.expectedUser.HTMLURL, *returnedUser.HTMLURL)
129+
assert.Equal(t, *tc.expectedUser.Type, *returnedUser.Type)
130+
})
131+
}
132+
}

0 commit comments

Comments
 (0)