Skip to content

feat: partition tools by product/feature #188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,16 @@ var (
if err != nil {
stdlog.Fatal("Failed to initialize logger:", err)
}

enabledToolsets := viper.GetStringSlice("toolsets")

logCommands := viper.GetBool("enable-command-logging")
cfg := runConfig{
readOnly: readOnly,
logger: logger,
logCommands: logCommands,
exportTranslations: exportTranslations,
enabledToolsets: enabledToolsets,
}
if err := runStdioServer(cfg); err != nil {
stdlog.Fatal("failed to run stdio server:", err)
Expand All @@ -62,26 +66,30 @@ func init() {
cobra.OnInitialize(initConfig)

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

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
_ = viper.BindPFlag("gh-host", rootCmd.PersistentFlags().Lookup("gh-host"))
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))

// Add subcommands
rootCmd.AddCommand(stdioCmd)
}

func initConfig() {
// Initialize Viper configuration
viper.SetEnvPrefix("APP")
viper.SetEnvPrefix("github")
viper.AutomaticEnv()
}

Expand All @@ -107,6 +115,7 @@ type runConfig struct {
logger *log.Logger
logCommands bool
exportTranslations bool
enabledToolsets []string
}

func runStdioServer(cfg runConfig) error {
Expand All @@ -115,18 +124,14 @@ func runStdioServer(cfg runConfig) error {
defer stop()

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

// Check GH_HOST env var first, then fall back to viper config
host := os.Getenv("GH_HOST")
if host == "" {
host = viper.GetString("gh-host")
}
host := viper.GetString("host")

if host != "" {
var err error
Expand All @@ -149,8 +154,40 @@ func runStdioServer(cfg runConfig) error {
hooks := &server.Hooks{
OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
}
// Create
ghServer := github.NewServer(getClient, version, cfg.readOnly, t, server.WithHooks(hooks))
// Create server
ghServer := github.NewServer(version, server.WithHooks(hooks))

enabled := cfg.enabledToolsets
dynamic := viper.GetBool("dynamic_toolsets")
if dynamic {
// filter "all" from the enabled toolsets
enabled = make([]string, 0, len(cfg.enabledToolsets))
for _, toolset := range cfg.enabledToolsets {
if toolset != "all" {
enabled = append(enabled, toolset)
}
}
}

// Create default toolsets
toolsets, err := github.InitToolsets(enabled, cfg.readOnly, getClient, t)
context := github.InitContextToolset(getClient, t)

if err != nil {
stdlog.Fatal("Failed to initialize toolsets:", err)
}

// Register resources with the server
github.RegisterResources(ghServer, getClient, t)
// Register the tools with the server
toolsets.RegisterTools(ghServer)
context.RegisterTools(ghServer)

if dynamic {
dynamic := github.InitDynamicToolset(ghServer, toolsets, t)
dynamic.RegisterTools(ghServer)
}

stdioServer := server.NewStdioServer(ghServer)

stdLogger := stdlog.New(cfg.logger.Writer(), "stdioserver", 0)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/docker/docker v28.0.4+incompatible
github.com/google/go-cmp v0.7.0
github.com/google/go-github/v69 v69.2.0
github.com/mark3labs/mcp-go v0.18.0
github.com/mark3labs/mcp-go v0.20.1
github.com/migueleliasweb/go-github-mock v1.1.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao=
github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
github.com/mark3labs/mcp-go v0.20.1 h1:E1Bbx9K8d8kQmDZ1QHblM38c7UU2evQ2LlkANk1U/zw=
github.com/mark3labs/mcp-go v0.20.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
github.com/migueleliasweb/go-github-mock v1.1.0 h1:GKaOBPsrPGkAKgtfuWY8MclS1xR6MInkx1SexJucMwE=
github.com/migueleliasweb/go-github-mock v1.1.0/go.mod h1:pYe/XlGs4BGMfRY4vmeixVsODHnVDDhJ9zoi0qzSMHc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
Expand Down
49 changes: 49 additions & 0 deletions pkg/github/context_tools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package github

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/github/github-mcp-server/pkg/translations"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

// GetMe creates a tool to get details of the authenticated user.
func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_me",
mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request include \"me\", \"my\"...")),
mcp.WithString("reason",
mcp.Description("Optional: reason the session was created"),
),
),
func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
user, resp, err := client.Users.Get(ctx, "")
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil
}

r, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("failed to marshal user: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}
132 changes: 132 additions & 0 deletions pkg/github/context_tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package github

import (
"context"
"encoding/json"
"net/http"
"testing"
"time"

"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v69/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_GetMe(t *testing.T) {
// Verify tool definition
mockClient := github.NewClient(nil)
tool, _ := GetMe(stubGetClientFn(mockClient), translations.NullTranslationHelper)

assert.Equal(t, "get_me", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "reason")
assert.Empty(t, tool.InputSchema.Required) // No required parameters

// Setup mock user response
mockUser := &github.User{
Login: github.Ptr("testuser"),
Name: github.Ptr("Test User"),
Email: github.Ptr("[email protected]"),
Bio: github.Ptr("GitHub user for testing"),
Company: github.Ptr("Test Company"),
Location: github.Ptr("Test Location"),
HTMLURL: github.Ptr("https://github.com/testuser"),
CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
Type: github.Ptr("User"),
Plan: &github.Plan{
Name: github.Ptr("pro"),
},
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedUser *github.User
expectedErrMsg string
}{
{
name: "successful get user",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
mockUser,
),
),
requestArgs: map[string]interface{}{},
expectError: false,
expectedUser: mockUser,
},
{
name: "successful get user with reason",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
mockUser,
),
),
requestArgs: map[string]interface{}{
"reason": "Testing API",
},
expectError: false,
expectedUser: mockUser,
},
{
name: "get user fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUser,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message": "Unauthorized"}`))
}),
),
),
requestArgs: map[string]interface{}{},
expectError: true,
expectedErrMsg: "failed to get user",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := GetMe(stubGetClientFn(client), translations.NullTranslationHelper)

// Create call request
request := createMCPRequest(tc.requestArgs)

// Call handler
result, err := handler(context.Background(), request)

// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}

require.NoError(t, err)

// Parse result and get text content if no error
textContent := getTextResult(t, result)

// Unmarshal and verify the result
var returnedUser github.User
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
require.NoError(t, err)

// Verify user details
assert.Equal(t, *tc.expectedUser.Login, *returnedUser.Login)
assert.Equal(t, *tc.expectedUser.Name, *returnedUser.Name)
assert.Equal(t, *tc.expectedUser.Email, *returnedUser.Email)
assert.Equal(t, *tc.expectedUser.Bio, *returnedUser.Bio)
assert.Equal(t, *tc.expectedUser.HTMLURL, *returnedUser.HTMLURL)
assert.Equal(t, *tc.expectedUser.Type, *returnedUser.Type)
})
}
}
Loading