Skip to content

Commit 1dea445

Browse files
feat: partition tools by product/feature
1 parent 62eed34 commit 1dea445

16 files changed

+877
-245
lines changed

cmd/github-mcp-server/main.go

+47-10
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ var (
4444
if err != nil {
4545
stdlog.Fatal("Failed to initialize logger:", err)
4646
}
47+
48+
enabledToolsets := viper.GetStringSlice("toolsets")
49+
4750
logCommands := viper.GetBool("enable-command-logging")
4851
cfg := runConfig{
4952
readOnly: readOnly,
5053
logger: logger,
5154
logCommands: logCommands,
5255
exportTranslations: exportTranslations,
56+
enabledToolsets: enabledToolsets,
5357
}
5458
if err := runStdioServer(cfg); err != nil {
5559
stdlog.Fatal("failed to run stdio server:", err)
@@ -62,26 +66,30 @@ func init() {
6266
cobra.OnInitialize(initConfig)
6367

6468
// Add global flags that will be shared by all commands
69+
rootCmd.PersistentFlags().StringSlice("toolsets", github.DefaultTools, "An optional comma separated list of groups of tools to allow, defaults to enabling all")
70+
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
6571
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
6672
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
6773
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
6874
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
6975
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
7076

7177
// Bind flag to viper
78+
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
79+
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
7280
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
7381
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
7482
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
7583
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
76-
_ = viper.BindPFlag("gh-host", rootCmd.PersistentFlags().Lookup("gh-host"))
84+
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
7785

7886
// Add subcommands
7987
rootCmd.AddCommand(stdioCmd)
8088
}
8189

8290
func initConfig() {
8391
// Initialize Viper configuration
84-
viper.SetEnvPrefix("APP")
92+
viper.SetEnvPrefix("github")
8593
viper.AutomaticEnv()
8694
}
8795

@@ -107,6 +115,7 @@ type runConfig struct {
107115
logger *log.Logger
108116
logCommands bool
109117
exportTranslations bool
118+
enabledToolsets []string
110119
}
111120

112121
func runStdioServer(cfg runConfig) error {
@@ -115,18 +124,14 @@ func runStdioServer(cfg runConfig) error {
115124
defer stop()
116125

117126
// Create GH client
118-
token := os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN")
127+
token := viper.GetString("personal_access_token")
119128
if token == "" {
120129
cfg.logger.Fatal("GITHUB_PERSONAL_ACCESS_TOKEN not set")
121130
}
122131
ghClient := gogithub.NewClient(nil).WithAuthToken(token)
123132
ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", version)
124133

125-
// Check GH_HOST env var first, then fall back to viper config
126-
host := os.Getenv("GH_HOST")
127-
if host == "" {
128-
host = viper.GetString("gh-host")
129-
}
134+
host := viper.GetString("host")
130135

131136
if host != "" {
132137
var err error
@@ -149,8 +154,40 @@ func runStdioServer(cfg runConfig) error {
149154
hooks := &server.Hooks{
150155
OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
151156
}
152-
// Create
153-
ghServer := github.NewServer(getClient, version, cfg.readOnly, t, server.WithHooks(hooks))
157+
// Create server
158+
ghServer := github.NewServer(version, server.WithHooks(hooks))
159+
160+
enabled := cfg.enabledToolsets
161+
dynamic := viper.GetBool("dynamic_toolsets")
162+
if dynamic {
163+
// filter "all" from the enabled toolsets
164+
enabled = make([]string, 0, len(cfg.enabledToolsets))
165+
for _, toolset := range cfg.enabledToolsets {
166+
if toolset != "all" {
167+
enabled = append(enabled, toolset)
168+
}
169+
}
170+
}
171+
172+
// Create default toolsets
173+
toolsets, err := github.InitToolsets(enabled, cfg.readOnly, getClient, t)
174+
context := github.InitContextToolset(getClient, t)
175+
176+
if err != nil {
177+
stdlog.Fatal("Failed to initialize toolsets:", err)
178+
}
179+
180+
// Register resources with the server
181+
github.RegisterResources(ghServer, getClient, t)
182+
// Register the tools with the server
183+
toolsets.RegisterTools(ghServer)
184+
context.RegisterTools(ghServer)
185+
186+
if dynamic {
187+
dynamic := github.InitDynamicToolset(ghServer, toolsets, t)
188+
dynamic.RegisterTools(ghServer)
189+
}
190+
154191
stdioServer := server.NewStdioServer(ghServer)
155192

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

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/docker/docker v28.0.4+incompatible
77
github.com/google/go-cmp v0.7.0
88
github.com/google/go-github/v69 v69.2.0
9-
github.com/mark3labs/mcp-go v0.18.0
9+
github.com/mark3labs/mcp-go v0.20.1
1010
github.com/migueleliasweb/go-github-mock v1.1.0
1111
github.com/sirupsen/logrus v1.9.3
1212
github.com/spf13/cobra v1.9.1

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
5757
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
5858
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
5959
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
60-
github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao=
61-
github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
60+
github.com/mark3labs/mcp-go v0.20.1 h1:E1Bbx9K8d8kQmDZ1QHblM38c7UU2evQ2LlkANk1U/zw=
61+
github.com/mark3labs/mcp-go v0.20.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
6262
github.com/migueleliasweb/go-github-mock v1.1.0 h1:GKaOBPsrPGkAKgtfuWY8MclS1xR6MInkx1SexJucMwE=
6363
github.com/migueleliasweb/go-github-mock v1.1.0/go.mod h1:pYe/XlGs4BGMfRY4vmeixVsODHnVDDhJ9zoi0qzSMHc=
6464
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=

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+
}

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)