From d01c91244eb643954b5e848b31a4fb0c7bbb33ce Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Thu, 22 May 2025 15:08:43 -0400 Subject: [PATCH 1/2] initial work on appauth --- cmd/github-mcp-server/main.go | 46 ++++- e2e/e2e_test.go | 314 +++++++++++++++++++++++++++++++--- go.mod | 8 +- go.sum | 7 + internal/ghmcp/server.go | 183 ++++++++++++++++++-- 5 files changed, 512 insertions(+), 46 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index fb716f78d..13ef0b6a9 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -29,11 +29,13 @@ var ( Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { - token := viper.GetString("personal_access_token") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + // Check authentication configuration + if err := validateAuthConfig(); err != nil { + return err } + token := viper.GetString("personal_access_token") + // If you're wondering why we're not using viper.GetStringSlice("toolsets"), // it's because viper doesn't handle comma-separated values correctly for env // vars when using GetStringSlice. @@ -74,6 +76,12 @@ func init() { 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.)") + // GitHub App authentication flags + rootCmd.PersistentFlags().Int64("gh-app-id", 0, "GitHub App ID for authentication") + rootCmd.PersistentFlags().Int64("gh-installation-id", 0, "GitHub App Installation ID for authentication") + rootCmd.PersistentFlags().String("gh-private-key-path", "", "Path to GitHub App private key file") + rootCmd.PersistentFlags().String("gh-private-key", "", "GitHub App private key content (alternative to private key file)") + // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) @@ -83,6 +91,12 @@ func init() { _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) + // Bind GitHub App authentication flags + _ = viper.BindPFlag("app_id", rootCmd.PersistentFlags().Lookup("gh-app-id")) + _ = viper.BindPFlag("installation_id", rootCmd.PersistentFlags().Lookup("gh-installation-id")) + _ = viper.BindPFlag("private_key_file_path", rootCmd.PersistentFlags().Lookup("gh-private-key-path")) + _ = viper.BindPFlag("private_key", rootCmd.PersistentFlags().Lookup("gh-private-key")) + // Add subcommands rootCmd.AddCommand(stdioCmd) } @@ -93,6 +107,32 @@ func initConfig() { viper.AutomaticEnv() } +// validateAuthConfig checks if either GitHub App authentication or PAT authentication is properly configured +func validateAuthConfig() error { + // Check GitHub App authentication + appID := viper.GetInt64("app_id") + installationID := viper.GetInt64("installation_id") + privateKeyPath := viper.GetString("private_key_file_path") + privateKey := viper.GetString("private_key") + + // Check if GitHub App authentication is partially configured + hasAppID := appID != 0 + hasInstallationID := installationID != 0 + hasPrivateKey := privateKeyPath != "" || privateKey != "" + + if (hasAppID || hasInstallationID || hasPrivateKey) && !(hasAppID && hasInstallationID && hasPrivateKey) { + return errors.New("incomplete GitHub App configuration: GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and either GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY must all be set") + } + + // Check PAT if GitHub App auth is not configured + token := viper.GetString("personal_access_token") + if !hasAppID && token == "" { + return errors.New("no authentication method configured: either set GITHUB_PERSONAL_ACCESS_TOKEN or configure GitHub App authentication with GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY") + } + + return nil +} + func main() { if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 5d8552ccf..539f4f796 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -7,14 +7,18 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "os" "os/exec" "slices" + "strconv" "strings" "sync" "testing" "time" + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/translations" @@ -29,6 +33,13 @@ var ( getTokenOnce sync.Once token string + getAppAuthOnce sync.Once + appID int64 + installationID int64 + privateKeyPath string + privateKeyContent string + isAppAuth bool + getHostOnce sync.Once host string @@ -41,12 +52,50 @@ func getE2EToken(t *testing.T) string { getTokenOnce.Do(func() { token = os.Getenv("GITHUB_MCP_SERVER_E2E_TOKEN") if token == "" { - t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set") + // Check if GitHub App authentication is configured + checkAppAuth() + if !isAppAuth { + t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set and GitHub App authentication is not configured") + } } }) return token } +// checkAppAuth checks if GitHub App authentication is configured +func checkAppAuth() { + getAppAuthOnce.Do(func() { + // Check for App ID + appIDStr := os.Getenv("GITHUB_MCP_SERVER_E2E_APP_ID") + if appIDStr != "" { + var err error + appID, err = strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse GITHUB_MCP_SERVER_E2E_APP_ID: %v\n", err) + return + } + } + + // Check for Installation ID + installationIDStr := os.Getenv("GITHUB_MCP_SERVER_E2E_INSTALLATION_ID") + if installationIDStr != "" { + var err error + installationID, err = strconv.ParseInt(installationIDStr, 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse GITHUB_MCP_SERVER_E2E_INSTALLATION_ID: %v\n", err) + return + } + } + + // Check for private key path or content + privateKeyPath = os.Getenv("GITHUB_MCP_SERVER_E2E_PRIVATE_KEY_PATH") + privateKeyContent = os.Getenv("GITHUB_MCP_SERVER_E2E_PRIVATE_KEY") + + // Determine if App Auth is configured + isAppAuth = appID != 0 && installationID != 0 && (privateKeyPath != "" || privateKeyContent != "") + }) +} + // getE2EHost ensures the environment variable is checked only once and returns the host func getE2EHost() string { getHostOnce.Do(func() { @@ -56,12 +105,45 @@ func getE2EHost() string { } func getRESTClient(t *testing.T) *gogithub.Client { - // Get token and ensure Docker image is built - token := getE2EToken(t) + // Check if GitHub App authentication is configured + checkAppAuth() + + var ghClient *gogithub.Client + + if isAppAuth { + // Create a GitHub client with GitHub App authentication + var transport http.RoundTripper + var err error + + if privateKeyContent != "" { + // If private key content was provided directly + privateKeyContent = strings.ReplaceAll(privateKeyContent, "\\n", "\n") + var itr *ghinstallation.Transport + itr, err = ghinstallation.New(http.DefaultTransport, appID, installationID, []byte(privateKeyContent)) + if err != nil { + t.Fatalf("Failed to create GitHub App transport from key content: %v", err) + } + transport = itr + } else { + // If private key file path was provided + var itr *ghinstallation.Transport + itr, err = ghinstallation.NewKeyFromFile(http.DefaultTransport, appID, installationID, privateKeyPath) + if err != nil { + t.Fatalf("Failed to create GitHub App transport from key file: %v", err) + } + transport = itr + } + + ghClient = gogithub.NewClient(&http.Client{Transport: transport}) + } else { + // Get token for PAT authentication + token := getE2EToken(t) - // Create a new GitHub client with the token - ghClient := gogithub.NewClient(nil).WithAuthToken(token) - if host := getE2EHost(); host != "https://github.com" { + // Create a new GitHub client with the token + ghClient = gogithub.NewClient(nil).WithAuthToken(token) + } + + if host := getE2EHost(); host != "" && host != "https://github.com" { var err error // Currently this works for GHEC because the API is exposed at the api subdomain and the path prefix // but it would be preferable to extract the host parsing from the main server logic, and use it here. @@ -76,12 +158,85 @@ func getRESTClient(t *testing.T) *gogithub.Client { func ensureDockerImageBuilt(t *testing.T) { buildOnce.Do(func() { 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() - buildError = err - if err != nil { - t.Logf("Docker build output: %s", string(output)) + + // Check if GitHub App authentication is configured with a private key file + checkAppAuth() + + if isAppAuth && privateKeyPath != "" { + // Use the e2e Dockerfile that supports private key files + // Create the Dockerfile.e2e if it doesn't exist + dockerfilePath := "../e2e/Dockerfile.e2e" + if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { + t.Log("Dockerfile.e2e not found, using default Dockerfile") + // Use the default Dockerfile + cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", "..") + output, err := cmd.CombinedOutput() + buildError = err + if err != nil { + t.Logf("Docker build output: %s", string(output)) + return + } + } else { + // Build the Docker image with the e2e Dockerfile + cmd := exec.Command("docker", "build", "-f", dockerfilePath, "-t", "github/e2e-github-mcp-server", "..") + output, err := cmd.CombinedOutput() + buildError = err + if err != nil { + t.Logf("Docker build output: %s", string(output)) + return + } + } + + // If using a private key file, create a Docker container with the private key mounted + if privateKeyPath != "" { + // Create a temporary container to copy the private key + containerID, err := exec.Command("docker", "create", "github/e2e-github-mcp-server").Output() + if err != nil { + buildError = fmt.Errorf("failed to create temporary container: %w", err) + return + } + + // Trim the container ID + containerIDStr := strings.TrimSpace(string(containerID)) + + // Copy the private key to the container + copyCmd := exec.Command("docker", "cp", privateKeyPath, fmt.Sprintf("%s:/keys/private-key.pem", containerIDStr)) + output, err := copyCmd.CombinedOutput() + if err != nil { + buildError = fmt.Errorf("failed to copy private key: %w", err) + t.Logf("Docker cp output: %s", string(output)) + return + } + + // Commit the container as a new image + commitCmd := exec.Command("docker", "commit", containerIDStr, "github/e2e-github-mcp-server") + output, err = commitCmd.CombinedOutput() + if err != nil { + buildError = fmt.Errorf("failed to commit container: %w", err) + t.Logf("Docker commit output: %s", string(output)) + return + } + + // Remove the temporary container + rmCmd := exec.Command("docker", "rm", containerIDStr) + output, err = rmCmd.CombinedOutput() + if err != nil { + t.Logf("Warning: Failed to remove temporary container: %v", err) + t.Logf("Docker rm output: %s", string(output)) + // Don't return an error here, as the container might already be removed + } + + // Update the private key path to point to the file inside the container + privateKeyPath = "/keys/private-key.pem" + } + } else { + // Use the default Dockerfile + cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", "..") + output, err := cmd.CombinedOutput() + buildError = err + if err != nil { + t.Logf("Docker build output: %s", string(output)) + } } }) @@ -107,9 +262,6 @@ func withToolsets(toolsets []string) clientOption { } func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client { - // Get token and ensure Docker image is built - token := getE2EToken(t) - // Create and configure options opts := &clientOpts{} @@ -130,8 +282,25 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client { "run", "-i", "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", // Personal access token is all required + } + + // Check if GitHub App authentication is configured + checkAppAuth() + + if isAppAuth { + // Add GitHub App authentication environment variables + args = append(args, + "-e", "GITHUB_APP_ID", + "-e", "GITHUB_INSTALLATION_ID") + + if privateKeyPath != "" { + args = append(args, "-e", "GITHUB_PRIVATE_KEY_FILE_PATH") + } else if privateKeyContent != "" { + args = append(args, "-e", "GITHUB_PRIVATE_KEY") + } + } else { + // Add PAT authentication environment variable + args = append(args, "-e", "GITHUB_PERSONAL_ACCESS_TOKEN") } host := getE2EHost() @@ -148,9 +317,27 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client { args = append(args, "github/e2e-github-mcp-server") // Construct the env vars for the MCP Client to execute docker with - dockerEnvVars := []string{ - fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", token), - fmt.Sprintf("GITHUB_TOOLSETS=%s", strings.Join(opts.enabledToolsets, ",")), + var dockerEnvVars []string + + if isAppAuth { + // Add GitHub App authentication environment variables + dockerEnvVars = append(dockerEnvVars, + fmt.Sprintf("GITHUB_APP_ID=%d", appID), + fmt.Sprintf("GITHUB_INSTALLATION_ID=%d", installationID)) + + if privateKeyPath != "" { + dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("GITHUB_PRIVATE_KEY_FILE_PATH=%s", privateKeyPath)) + } else if privateKeyContent != "" { + dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("GITHUB_PRIVATE_KEY=%s", privateKeyContent)) + } + } else { + // Add PAT authentication environment variable + token := getE2EToken(t) + dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", token)) + } + + if len(opts.enabledToolsets) > 0 { + dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("GITHUB_TOOLSETS=%s", strings.Join(opts.enabledToolsets, ","))) } if host != "" { @@ -171,12 +358,27 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client { enabledToolsets = github.DefaultTools } - ghServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{ - Token: token, + // Create the MCP server config + serverConfig := ghmcp.MCPServerConfig{ EnabledToolsets: enabledToolsets, Host: getE2EHost(), Translator: translations.NullTranslationHelper, - }) + } + + // Check if GitHub App authentication is configured + checkAppAuth() + + if isAppAuth { + // Configure the server to use GitHub App authentication + // Note: The server will use the environment variables for GitHub App authentication + // We don't need to set the token in the config + } else { + // Configure the server to use PAT authentication + token := getE2EToken(t) + serverConfig.Token = token + } + + ghServer, err := ghmcp.NewMCPServer(serverConfig) require.NoError(t, err, "expected to construct MCP server successfully") t.Log("Starting In Process MCP client...") @@ -209,6 +411,14 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client { func TestGetMe(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestGetMe for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -272,6 +482,14 @@ func TestToolsets(t *testing.T) { func TestTags(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestTags for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -410,6 +628,14 @@ func TestTags(t *testing.T) { func TestFileDeletion(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestFileDeletion for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -602,6 +828,14 @@ func TestFileDeletion(t *testing.T) { func TestDirectoryDeletion(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestDirectoryDeletion for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -801,6 +1035,14 @@ func TestRequestCopilotReview(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestRequestCopilotReview for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -932,7 +1174,7 @@ func TestRequestCopilotReview(t *testing.T) { // Finally, get requested reviews and see copilot is in there // MCP Server doesn't support requesting reviews yet, but we can use the GitHub Client - ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + ghClient := getRESTClient(t) t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName) reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1, nil) require.NoError(t, err, "expected to get review requests successfully") @@ -946,6 +1188,14 @@ func TestRequestCopilotReview(t *testing.T) { func TestPullRequestAtomicCreateAndSubmit(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestPullRequestAtomicCreateAndSubmit for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -1106,6 +1356,14 @@ func TestPullRequestAtomicCreateAndSubmit(t *testing.T) { func TestPullRequestReviewCommentSubmit(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestPullRequestReviewCommentSubmit for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() @@ -1351,6 +1609,14 @@ func TestPullRequestReviewCommentSubmit(t *testing.T) { func TestPullRequestReviewDeletion(t *testing.T) { t.Parallel() + // Check if GitHub App authentication is configured + checkAppAuth() + + // Skip this test if using GitHub App authentication since it doesn't have user permissions + if isAppAuth { + t.Skip("Skipping TestPullRequestReviewDeletion for GitHub App authentication - requires user:email scope") + } + mcpClient := setupMCPClient(t) ctx := context.Background() diff --git a/go.mod b/go.mod index 5c9bc081f..75c58c5fe 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module github.com/github/github-mcp-server -go 1.23.7 +go 1.23.4 require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.9.0 github.com/google/go-github/v69 v69.2.0 github.com/mark3labs/mcp-go v0.28.0 github.com/migueleliasweb/go-github-mock v1.3.0 @@ -12,6 +13,11 @@ require ( github.com/stretchr/testify v1.10.0 ) +require ( + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/google/go-github/v57 v57.0.0 // indirect +) + require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect diff --git a/go.sum b/go.sum index 6d3d29760..5068b9536 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/bradleyfalzon/ghinstallation/v2 v2.9.0 h1:HmxIYqnxubRYcYGRc5v3wUekmo5Wv2uX3gukmWJ0AFk= +github.com/bradleyfalzon/ghinstallation/v2 v2.9.0/go.mod h1:wmkTDJf8CmVypxE8ijIStFnKoTa6solK5QfdmJrP9KI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -9,9 +11,14 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs= +github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw= github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a75a9e0cb..0ac0df446 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "strings" "syscall" + "github.com/bradleyfalzon/ghinstallation/v2" "github.com/github/github-mcp-server/pkg/github" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/translations" @@ -20,8 +21,164 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" "github.com/sirupsen/logrus" + "github.com/spf13/viper" ) +// createClient returns an appropriate GitHub client based on available authentication methods. +// It tries GitHub App authentication first, then falls back to PAT authentication. +func createClient(cfg MCPServerConfig) (*gogithub.Client, error) { + // Try GitHub App authentication first + appID := viper.GetInt64("app_id") + installationID := viper.GetInt64("installation_id") + + // Check for private key - can be provided as file path or direct content + privateKeyPath := viper.GetString("private_key_file_path") + privateKeyContent := viper.GetString("private_key") + + // If we have the necessary GitHub App credentials + if appID != 0 && installationID != 0 && (privateKeyPath != "" || privateKeyContent != "") { + var itr *ghinstallation.Transport + var err error + + // Create transport based on how the private key was provided + if privateKeyContent != "" { + // If private key content was provided directly + // The content might be base64 encoded or have escaped newlines + privateKeyContent = strings.ReplaceAll(privateKeyContent, "\\n", "\n") + itr, err = ghinstallation.New(http.DefaultTransport, appID, installationID, []byte(privateKeyContent)) + } else { + // If private key file path was provided + itr, err = ghinstallation.NewKeyFromFile(http.DefaultTransport, appID, installationID, privateKeyPath) + } + + if err != nil { + return nil, fmt.Errorf("failed to create GitHub App transport: %w", err) + } + + // Set the base URL if a custom host is specified + if cfg.Host != "" { + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) + } + itr.BaseURL = apiHost.baseRESTURL.String() + } + + // Create client with the transport + client := gogithub.NewClient(&http.Client{Transport: itr}) + client.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + + return client, nil + } + + // Fall back to PAT authentication + token := cfg.Token + if token == "" { + return nil, fmt.Errorf("neither GitHub App credentials nor personal access token provided") + } + + // Create client with PAT + client := gogithub.NewClient(nil).WithAuthToken(token) + client.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + + // Set custom API URL if specified + if cfg.Host != "" { + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) + } + client.BaseURL = apiHost.baseRESTURL + client.UploadURL = apiHost.uploadURL + } + + return client, nil +} + +// createGQLClient returns an appropriate GitHub GraphQL client based on available authentication methods. +func createGQLClient(cfg MCPServerConfig) (*githubv4.Client, *http.Client, error) { + // Try GitHub App authentication first + appID := viper.GetInt64("app_id") + installationID := viper.GetInt64("installation_id") + + // Check for private key - can be provided as file path or direct content + privateKeyPath := viper.GetString("private_key_file_path") + privateKeyContent := viper.GetString("private_key") + + // If we have the necessary GitHub App credentials + if appID != 0 && installationID != 0 && (privateKeyPath != "" || privateKeyContent != "") { + var itr *ghinstallation.Transport + var err error + + // Create transport based on how the private key was provided + if privateKeyContent != "" { + // If private key content was provided directly + privateKeyContent = strings.ReplaceAll(privateKeyContent, "\\n", "\n") + itr, err = ghinstallation.New(http.DefaultTransport, appID, installationID, []byte(privateKeyContent)) + } else { + // If private key file path was provided + itr, err = ghinstallation.NewKeyFromFile(http.DefaultTransport, appID, installationID, privateKeyPath) + } + + if err != nil { + return nil, nil, fmt.Errorf("failed to create GitHub App transport: %w", err) + } + + // Set the base URL if a custom host is specified + if cfg.Host != "" { + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse API host: %w", err) + } + itr.BaseURL = apiHost.baseRESTURL.String() + } + + // Create HTTP client with transport + httpClient := &http.Client{Transport: itr} + + // Create GraphQL client + var gqlClient *githubv4.Client + if cfg.Host != "" { + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse API host: %w", err) + } + gqlClient = githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), httpClient) + } else { + gqlClient = githubv4.NewClient(httpClient) + } + + return gqlClient, httpClient, nil + } + + // Fall back to PAT authentication + token := cfg.Token + if token == "" { + return nil, nil, fmt.Errorf("neither GitHub App credentials nor personal access token provided") + } + + // Create HTTP client with bearer auth transport + httpClient := &http.Client{ + Transport: &bearerAuthTransport{ + transport: http.DefaultTransport, + token: token, + }, + } + + // Create GraphQL client + var gqlClient *githubv4.Client + if cfg.Host != "" { + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse API host: %w", err) + } + gqlClient = githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), httpClient) + } else { + gqlClient = githubv4.NewClient(httpClient) + } + + return gqlClient, httpClient, nil +} + type MCPServerConfig struct { // Version of the server Version string @@ -48,27 +205,17 @@ type MCPServerConfig struct { } func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { - apiHost, err := parseAPIHost(cfg.Host) + // Create REST client + restClient, err := createClient(cfg) if err != nil { - return nil, fmt.Errorf("failed to parse API host: %w", err) + return nil, fmt.Errorf("failed to create GitHub REST client: %w", err) } - // Construct our REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) - restClient.BaseURL = apiHost.baseRESTURL - restClient.UploadURL = apiHost.uploadURL - - // Construct our GraphQL client - // We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already - // did the necessary API host parsing so that github.com will return the correct URL anyway. - gqlHTTPClient := &http.Client{ - Transport: &bearerAuthTransport{ - transport: http.DefaultTransport, - token: cfg.Token, - }, - } // We're going to wrap the Transport later in beforeInit - gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) + // Create GraphQL client + gqlClient, gqlHTTPClient, err := createGQLClient(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub GraphQL client: %w", err) + } // When a client send an initialize request, update the user agent to include the client info. beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) { From 25598bd776f6c18ffad076148fbe043359e8a914 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Fri, 23 May 2025 11:33:54 -0400 Subject: [PATCH 2/2] Add support for accessing public repositories when using GitHub App authentication --- .gitignore | 3 + README.md | 286 +++++++++++++++++++++++++++++- cmd/test-client/main.go | 135 ++++++++++++++ e2e/Dockerfile.e2e | 32 ++++ internal/ghmcp/server.go | 7 +- pkg/github/query_helpers.go | 41 +++++ pkg/github/repo_access.go | 161 +++++++++++++++++ pkg/github/repo_access_test.go | 169 ++++++++++++++++++ pkg/github/repositories.go | 6 + pkg/github/repository_resource.go | 3 + pkg/github/search.go | 14 ++ 11 files changed, 846 insertions(+), 11 deletions(-) create mode 100644 cmd/test-client/main.go create mode 100644 e2e/Dockerfile.e2e create mode 100644 pkg/github/query_helpers.go create mode 100644 pkg/github/repo_access.go create mode 100644 pkg/github/repo_access_test.go diff --git a/.gitignore b/.gitignore index 12649366d..e1132e86d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .idea cmd/github-mcp-server/github-mcp-server +github-mcp-server +test-client +cmd/test-client/test-client # VSCode .vscode/* diff --git a/README.md b/README.md index 352bb50eb..d9b68be8e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,70 @@ automation and interaction capabilities for developers and tools. 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 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`. -3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -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)). +3. For authentication, you have two options: + - **Personal Access Token (PAT)**: [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). Enable the permissions that you feel comfortable granting your AI tools. + - **GitHub App Authentication**: Use a GitHub App's credentials for authentication. This requires an App ID, Installation ID, and private key. + +## Authentication + +The GitHub MCP Server supports two authentication methods: + +### Personal Access Token (PAT) + +You can authenticate using a GitHub Personal Access Token by setting the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + ghcr.io/github/github-mcp-server +``` + +### GitHub App Authentication + +Alternatively, you can authenticate using GitHub App credentials. This requires: + +1. **App ID**: The GitHub App's ID +2. **Installation ID**: The ID of the app installation +3. **Private Key**: The app's private key (as a file path or direct content) + +You can provide these credentials in two ways: + +#### Using a Private Key File + +```bash +docker run -i --rm \ + -e GITHUB_APP_ID= \ + -e GITHUB_INSTALLATION_ID= \ + -e GITHUB_PRIVATE_KEY_FILE_PATH=/path/to/private-key.pem \ + -v /local/path/to/private-key.pem:/path/to/private-key.pem \ + ghcr.io/github/github-mcp-server +``` + +#### Using Private Key Content Directly + +```bash +docker run -i --rm \ + -e GITHUB_APP_ID= \ + -e GITHUB_INSTALLATION_ID= \ + -e GITHUB_PRIVATE_KEY="$(cat /path/to/private-key.pem)" \ + ghcr.io/github/github-mcp-server +``` + +**Note**: GitHub App authentication tokens are automatically refreshed before they expire. + +### Public Repository Access with GitHub App Authentication + +By default, GitHub Apps can only access repositories they are explicitly installed on. However, the GitHub MCP Server includes a feature that allows you to access public repositories even when using GitHub App authentication: + +- When accessing a repository, the server first attempts to use the GitHub App credentials +- If that fails with a permission error (e.g., the app isn't installed on the repository), the server automatically falls back to anonymous access for public repositories +- This happens transparently without any additional configuration + +This feature enables your AI tools to access both: +- Private repositories where the GitHub App is installed +- Public repositories across GitHub, even if the app is not installed on them + +The server intelligently caches repository access information to minimize API calls and maintain optimal performance. ## Installation @@ -25,7 +87,9 @@ The MCP server can use many of the GitHub APIs, so enable the permissions that y For quick installation, use one of the one-click install buttons at the top of this README. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. -For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`. +For manual installation, add one of the following JSON blocks to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`. + +#### Using Personal Access Token (PAT) ```json { @@ -58,8 +122,60 @@ For manual installation, add the following JSON block to your User Settings (JSO } ``` -Optionally, you can add a similar example (i.e. without the mcp key) to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. +#### Using GitHub App Authentication +```json +{ + "mcp": { + "inputs": [ + { + "type": "promptString", + "id": "github_app_id", + "description": "GitHub App ID", + "password": false + }, + { + "type": "promptString", + "id": "github_installation_id", + "description": "GitHub App Installation ID", + "password": false + }, + { + "type": "promptString", + "id": "github_private_key", + "description": "GitHub App Private Key", + "password": true + } + ], + "servers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_APP_ID", + "-e", + "GITHUB_INSTALLATION_ID", + "-e", + "GITHUB_PRIVATE_KEY", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_APP_ID": "${input:github_app_id}", + "GITHUB_INSTALLATION_ID": "${input:github_installation_id}", + "GITHUB_PRIVATE_KEY": "${input:github_private_key}" + } + } + } + } +} +``` + +Optionally, you can add a similar example (i.e. without the mcp key) to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. Here are examples for both authentication methods: + +#### Workspace Configuration with PAT ```json { @@ -88,13 +204,60 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c } } } - ``` -More about using MCP server tools in VS Code's [agent mode documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). +#### Workspace Configuration with GitHub App Authentication + +```json +{ + "inputs": [ + { + "type": "promptString", + "id": "github_app_id", + "description": "GitHub App ID", + "password": false + }, + { + "type": "promptString", + "id": "github_installation_id", + "description": "GitHub App Installation ID", + "password": false + }, + { + "type": "promptString", + "id": "github_private_key", + "description": "GitHub App Private Key", + "password": true + } + ], + "servers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_APP_ID", + "-e", + "GITHUB_INSTALLATION_ID", + "-e", + "GITHUB_PRIVATE_KEY", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_APP_ID": "${input:github_app_id}", + "GITHUB_INSTALLATION_ID": "${input:github_installation_id}", + "GITHUB_PRIVATE_KEY": "${input:github_private_key}" + } + } + } +} ### Usage with Claude Desktop +#### Using Personal Access Token (PAT) + ```json { "mcpServers": { @@ -116,10 +279,41 @@ More about using MCP server tools in VS Code's [agent mode documentation](https: } ``` +#### Using GitHub App Authentication + +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_APP_ID", + "-e", + "GITHUB_INSTALLATION_ID", + "-e", + "GITHUB_PRIVATE_KEY", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_APP_ID": "", + "GITHUB_INSTALLATION_ID": "", + "GITHUB_PRIVATE_KEY": "" + } + } + } +} +``` + ### Build from source If you don't have Docker, you can use `go build` to build the binary in the -`cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`. For example: +`cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the appropriate authentication environment variables. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`. For example: + +#### Using Personal Access Token (PAT) ```JSON { @@ -137,6 +331,26 @@ If you don't have Docker, you can use `go build` to build the binary in the } ``` +#### Using GitHub App Authentication + +```JSON +{ + "mcp": { + "servers": { + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio"], + "env": { + "GITHUB_APP_ID": "", + "GITHUB_INSTALLATION_ID": "", + "GITHUB_PRIVATE_KEY": "" + } + } + } + } +} +``` + ## Tool Configuration 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. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size. @@ -175,6 +389,8 @@ The environment variable `GITHUB_TOOLSETS` takes precedence over the command lin When using Docker, you can pass the toolsets as environment variables: +#### With Personal Access Token (PAT) + ```bash docker run -i --rm \ -e GITHUB_PERSONAL_ACCESS_TOKEN= \ @@ -182,6 +398,17 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` +#### With GitHub App Authentication + +```bash +docker run -i --rm \ + -e GITHUB_APP_ID= \ + -e GITHUB_INSTALLATION_ID= \ + -e GITHUB_PRIVATE_KEY="$(cat /path/to/private-key.pem)" \ + -e GITHUB_TOOLSETS="repos,issues,pull_requests,code_security,experiments" \ + ghcr.io/github/github-mcp-server +``` + ### The "all" Toolset The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: @@ -212,6 +439,8 @@ When using the binary, you can pass the `--dynamic-toolsets` flag. When using Docker, you can pass the toolsets as environment variables: +#### With Personal Access Token (PAT) + ```bash docker run -i --rm \ -e GITHUB_PERSONAL_ACCESS_TOKEN= \ @@ -219,13 +448,26 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` +#### With GitHub App Authentication + +```bash +docker run -i --rm \ + -e GITHUB_APP_ID= \ + -e GITHUB_INSTALLATION_ID= \ + -e GITHUB_PRIVATE_KEY="$(cat /path/to/private-key.pem)" \ + -e GITHUB_DYNAMIC_TOOLSETS=1 \ + ghcr.io/github/github-mcp-server +``` + ## GitHub Enterprise Server The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set the GitHub Enterprise Server hostname. Prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://` which GitHub Enterprise Server does not support. -``` json +### With Personal Access Token (PAT) + +```json "github": { "command": "docker", "args": [ @@ -245,6 +487,34 @@ Prefix the hostname with the `https://` URI scheme, as it otherwise defaults to } ``` +### With GitHub App Authentication + +```json +"github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_APP_ID", + "-e", + "GITHUB_INSTALLATION_ID", + "-e", + "GITHUB_PRIVATE_KEY", + "-e", + "GITHUB_HOST", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_APP_ID": "${input:github_app_id}", + "GITHUB_INSTALLATION_ID": "${input:github_installation_id}", + "GITHUB_PRIVATE_KEY": "${input:github_private_key}", + "GITHUB_HOST": "https://" + } +} +``` + ## i18n / Overriding Descriptions The descriptions of the tools can be overridden by creating a diff --git a/cmd/test-client/main.go b/cmd/test-client/main.go new file mode 100644 index 000000000..e1b87f420 --- /dev/null +++ b/cmd/test-client/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +func main() { + // Get authentication credentials from environment variables + appID := os.Getenv("GITHUB_APP_ID") + installationID := os.Getenv("GITHUB_INSTALLATION_ID") + privateKeyPath := os.Getenv("GITHUB_PRIVATE_KEY_FILE_PATH") + privateKey := os.Getenv("GITHUB_PRIVATE_KEY") + token := os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + + // Check if we have authentication credentials + if appID == "" || installationID == "" || (privateKeyPath == "" && privateKey == "") { + if token == "" { + fmt.Println("Error: No authentication credentials provided.") + fmt.Println("Please set either:") + fmt.Println(" - GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and either GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY") + fmt.Println(" - GITHUB_PERSONAL_ACCESS_TOKEN") + os.Exit(1) + } + } + + // Prepare environment variables for the client + var env []string + if appID != "" && installationID != "" { + env = append(env, "GITHUB_APP_ID="+appID) + env = append(env, "GITHUB_INSTALLATION_ID="+installationID) + if privateKeyPath != "" { + env = append(env, "GITHUB_PRIVATE_KEY_FILE_PATH="+privateKeyPath) + } else if privateKey != "" { + env = append(env, "GITHUB_PRIVATE_KEY="+privateKey) + } + } else if token != "" { + env = append(env, "GITHUB_PERSONAL_ACCESS_TOKEN="+token) + } + + // Create the client + mcpClient, err := client.NewStdioMCPClient( + "./github-mcp-server", + env, + "stdio", + ) + if err != nil { + fmt.Printf("Error creating client: %v\n", err) + os.Exit(1) + } + defer mcpClient.Close() + + // Initialize the client + 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: "test-client", + Version: "0.0.1", + } + + result, err := mcpClient.Initialize(ctx, request) + if err != nil { + fmt.Printf("Error initializing client: %v\n", err) + os.Exit(1) + } + fmt.Printf("Server info: %s %s\n", result.ServerInfo.Name, result.ServerInfo.Version) + + // Try to access a public repository + getFileContentsRequest := mcp.CallToolRequest{} + getFileContentsRequest.Params.Name = "get_file_contents" + getFileContentsRequest.Params.Arguments = map[string]any{ + "owner": "github", + "repo": "github-mcp-server", + "path": "README.md", + } + + fmt.Println("Getting file contents for github/github-mcp-server/README.md...") + resp, err := mcpClient.CallTool(ctx, getFileContentsRequest) + if err != nil { + fmt.Printf("Error calling get_file_contents: %v\n", err) + os.Exit(1) + } + + if resp.IsError { + fmt.Printf("Error response: IsError=true\n") + os.Exit(1) + } + + fmt.Printf("Got response with %d content items\n", len(resp.Content)) + if len(resp.Content) > 0 { + textContent, ok := resp.Content[0].(mcp.TextContent) + if ok { + fmt.Printf("Content type: %s\n", textContent.Type) + fmt.Printf("Content length: %d bytes\n", len(textContent.Text)) + } + } + + // Try to access a private repository + getFileContentsRequest = mcp.CallToolRequest{} + getFileContentsRequest.Params.Name = "get_file_contents" + getFileContentsRequest.Params.Arguments = map[string]any{ + "owner": "goose-slackbot", + "repo": "goose-slackbot", + "path": "README.md", + } + + fmt.Println("\nGetting file contents for goose-slackbot/goose-slackbot/README.md...") + resp, err = mcpClient.CallTool(ctx, getFileContentsRequest) + if err != nil { + fmt.Printf("Error calling get_file_contents: %v\n", err) + os.Exit(1) + } + + if resp.IsError { + fmt.Printf("Error response: IsError=true\n") + os.Exit(1) + } + + fmt.Printf("Got response with %d content items\n", len(resp.Content)) + if len(resp.Content) > 0 { + textContent, ok := resp.Content[0].(mcp.TextContent) + if ok { + fmt.Printf("Content type: %s\n", textContent.Type) + fmt.Printf("Content length: %d bytes\n", len(textContent.Text)) + } + } +} diff --git a/e2e/Dockerfile.e2e b/e2e/Dockerfile.e2e new file mode 100644 index 000000000..dc57134ba --- /dev/null +++ b/e2e/Dockerfile.e2e @@ -0,0 +1,32 @@ +FROM golang:1.24.3-alpine AS build +ARG VERSION="dev" + +# Set the working directory +WORKDIR /build + +# Install git +RUN --mount=type=cache,target=/var/cache/apk \ + apk add git + +# Build the server +# go build automatically download required module dependencies to /go/pkg/mod +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=bind,target=. \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o /bin/github-mcp-server cmd/github-mcp-server/main.go + +# Make a stage to run the app +FROM gcr.io/distroless/base-debian12 +# Set the working directory +WORKDIR /server +# Copy the binary from the build stage +COPY --from=build /bin/github-mcp-server . + +# Create a directory for private keys if needed +WORKDIR /keys +# Back to server directory +WORKDIR /server + +# Command to run the server +CMD ["./github-mcp-server", "stdio"] diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 0ac0df446..2eb96268e 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -11,6 +11,7 @@ import ( "os/signal" "strings" "syscall" + "time" "github.com/bradleyfalzon/ghinstallation/v2" "github.com/github/github-mcp-server/pkg/github" @@ -251,9 +252,9 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { } } - getClient := func(_ context.Context) (*gogithub.Client, error) { - return restClient, nil // closing over client - } + // Create repository-aware client factory with 1-hour cache TTL + clientFactory := github.NewRepoAwareClientFactory(restClient, 1*time.Hour) + getClient := clientFactory.GetClientFn() getGQLClient := func(_ context.Context) (*githubv4.Client, error) { return gqlClient, nil // closing over client diff --git a/pkg/github/query_helpers.go b/pkg/github/query_helpers.go new file mode 100644 index 000000000..e90581e0f --- /dev/null +++ b/pkg/github/query_helpers.go @@ -0,0 +1,41 @@ +package github + +import ( + "regexp" + "strings" +) + +// repoQueryInfo holds extracted repository information from a query +type repoQueryInfo struct { + owner string + repo string +} + +// extractRepoFromQuery attempts to extract repository owner and name from a search query +// It looks for patterns like "repo:owner/repo" or "owner/repo" +func extractRepoFromQuery(query string) repoQueryInfo { + // Check for repo:owner/repo pattern + repoPattern := regexp.MustCompile(`repo:([^/\s]+)/([^/\s]+)`) + matches := repoPattern.FindStringSubmatch(query) + if len(matches) == 3 { + return repoQueryInfo{ + owner: matches[1], + repo: matches[2], + } + } + + // Check for owner/repo pattern + ownerRepoPattern := regexp.MustCompile(`\b([^/\s]+)/([^/\s]+)\b`) + matches = ownerRepoPattern.FindStringSubmatch(query) + if len(matches) == 3 { + // Make sure it's not part of another pattern + if !strings.Contains(query, "repo:"+matches[0]) { + return repoQueryInfo{ + owner: matches[1], + repo: matches[2], + } + } + } + + return repoQueryInfo{} +} diff --git a/pkg/github/repo_access.go b/pkg/github/repo_access.go new file mode 100644 index 000000000..49243393a --- /dev/null +++ b/pkg/github/repo_access.go @@ -0,0 +1,161 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "github.com/google/go-github/v69/github" +) + +// RepoAccessType indicates how a repository should be accessed +type RepoAccessType int + +const ( + // RepoAccessPrivate indicates the repository should be accessed with authentication + RepoAccessPrivate RepoAccessType = iota + // RepoAccessPublic indicates the repository should be accessed anonymously + RepoAccessPublic + // RepoAccessUnavailable indicates the repository is not accessible + RepoAccessUnavailable +) + +// accessCacheEntry represents a cached repository access result +type accessCacheEntry struct { + accessType RepoAccessType + expiry time.Time +} + +// RepoAccessCache caches repository access information +type RepoAccessCache struct { + cache map[string]accessCacheEntry + mu sync.RWMutex + ttl time.Duration +} + +// NewRepoAccessCache creates a new repository access cache +func NewRepoAccessCache(ttl time.Duration) *RepoAccessCache { + return &RepoAccessCache{ + cache: make(map[string]accessCacheEntry), + ttl: ttl, + } +} + +// GetRepoAccessType determines how a repository should be accessed +func (c *RepoAccessCache) GetRepoAccessType(ctx context.Context, authClient *github.Client, owner, repo string) (RepoAccessType, error) { + cacheKey := owner + "/" + repo + + // Check cache first + c.mu.RLock() + entry, found := c.cache[cacheKey] + c.mu.RUnlock() + + if found && time.Now().Before(entry.expiry) { + return entry.accessType, nil + } + + // Cache miss or expired, determine access type + accessType, err := determineRepoAccessType(ctx, authClient, owner, repo) + if err != nil { + return RepoAccessUnavailable, err + } + + // Update cache + c.mu.Lock() + c.cache[cacheKey] = accessCacheEntry{ + accessType: accessType, + expiry: time.Now().Add(c.ttl), + } + c.mu.Unlock() + + return accessType, nil +} + +// determineRepoAccessType checks how a repository should be accessed +func determineRepoAccessType(ctx context.Context, authClient *github.Client, owner, repo string) (RepoAccessType, error) { + // First try with authenticated client + _, resp, err := authClient.Repositories.Get(ctx, owner, repo) + + // If successful, we can access it with the authenticated client + if err == nil { + return RepoAccessPrivate, nil + } + + // Check if it's a permission error (403) or not found error (404) + if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) { + // Try anonymous access + anonClient := github.NewClient(nil) + _, anonResp, anonErr := anonClient.Repositories.Get(ctx, owner, repo) + + // If anonymous access succeeds, it's a public repo + if anonErr == nil { + return RepoAccessPublic, nil + } + + // If anonymous access also fails with 404, repo doesn't exist + if anonResp != nil && anonResp.StatusCode == http.StatusNotFound { + return RepoAccessUnavailable, fmt.Errorf("repository %s/%s does not exist or is not accessible", owner, repo) + } + + // Some other error with anonymous access + return RepoAccessUnavailable, fmt.Errorf("failed to access repository anonymously: %w", anonErr) + } + + // Some other error with authenticated access + return RepoAccessUnavailable, fmt.Errorf("failed to access repository with authentication: %w", err) +} + +// RepoAwareClientFactory creates clients based on repository access type +type RepoAwareClientFactory struct { + authClient *github.Client + cache *RepoAccessCache +} + +// NewRepoAwareClientFactory creates a new factory for repository-aware clients +func NewRepoAwareClientFactory(authClient *github.Client, cacheTTL time.Duration) *RepoAwareClientFactory { + return &RepoAwareClientFactory{ + authClient: authClient, + cache: NewRepoAccessCache(cacheTTL), + } +} + +// GetClientForRepo returns the appropriate client for a repository +func (f *RepoAwareClientFactory) GetClientForRepo(ctx context.Context, owner, repo string) (*github.Client, error) { + accessType, err := f.cache.GetRepoAccessType(ctx, f.authClient, owner, repo) + if err != nil { + return nil, err + } + + switch accessType { + case RepoAccessPrivate: + return f.authClient, nil + case RepoAccessPublic: + return github.NewClient(nil), nil + default: + return nil, fmt.Errorf("repository %s/%s is not accessible", owner, repo) + } +} + +// WithRepoContext adds repository information to the context +func WithRepoContext(ctx context.Context, owner, repo string) context.Context { + ctx = context.WithValue(ctx, "github.owner", owner) + ctx = context.WithValue(ctx, "github.repo", repo) + return ctx +} + +// GetClientFn returns a function that gets the appropriate client based on context +func (f *RepoAwareClientFactory) GetClientFn() GetClientFn { + return func(ctx context.Context) (*github.Client, error) { + owner, ok1 := ctx.Value("github.owner").(string) + repo, ok2 := ctx.Value("github.repo").(string) + + if !ok1 || !ok2 { + // If we can't determine the repo, use authenticated client + return f.authClient, nil + } + + return f.GetClientForRepo(ctx, owner, repo) + } +} diff --git a/pkg/github/repo_access_test.go b/pkg/github/repo_access_test.go new file mode 100644 index 000000000..fbd434473 --- /dev/null +++ b/pkg/github/repo_access_test.go @@ -0,0 +1,169 @@ +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/google/go-github/v69/github" + "github.com/stretchr/testify/assert" +) + +func TestRepoAccessCache_GetRepoAccessType(t *testing.T) { + // Create a test server that responds differently based on the request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this is an authenticated request + authHeader := r.Header.Get("Authorization") + + switch r.URL.Path { + case "/repos/owner/private-repo": + if authHeader != "" { + // Authenticated request to private repo succeeds + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"private-repo","private":true}`)) + } else { + // Unauthenticated request to private repo fails + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + } + case "/repos/owner/public-repo": + if authHeader != "" { + // Authenticated request to public repo fails with 403 + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message":"Resource not accessible by integration"}`)) + } else { + // Unauthenticated request to public repo succeeds + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"public-repo","private":false}`)) + } + case "/repos/owner/nonexistent-repo": + // Both authenticated and unauthenticated requests fail for nonexistent repo + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + default: + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + } + })) + defer server.Close() + + // Create an authenticated client + authClient := github.NewClient(nil) + authClient.BaseURL, _ = url.Parse(server.URL + "/") + authClient = authClient.WithAuthToken("test-token") + + // Create the cache + cache := NewRepoAccessCache(10 * time.Millisecond) + + // Test private repository + accessType, err := cache.GetRepoAccessType(context.Background(), authClient, "owner", "private-repo") + assert.NoError(t, err) + assert.Equal(t, RepoAccessPrivate, accessType) + + // Test public repository + accessType, err = cache.GetRepoAccessType(context.Background(), authClient, "owner", "public-repo") + assert.NoError(t, err) + assert.Equal(t, RepoAccessPublic, accessType) // Should be public because auth client fails but anon succeeds + + // Test nonexistent repository + accessType, err = cache.GetRepoAccessType(context.Background(), authClient, "owner", "nonexistent-repo") + assert.Error(t, err) + assert.Equal(t, RepoAccessUnavailable, accessType) + + // Test caching + // This should use the cached result without making a request + accessType, err = cache.GetRepoAccessType(context.Background(), authClient, "owner", "private-repo") + assert.NoError(t, err) + assert.Equal(t, RepoAccessPrivate, accessType) + + // Wait for cache to expire + time.Sleep(15 * time.Millisecond) + + // This should make a new request + accessType, err = cache.GetRepoAccessType(context.Background(), authClient, "owner", "private-repo") + assert.NoError(t, err) + assert.Equal(t, RepoAccessPrivate, accessType) +} + +func TestRepoAwareClientFactory_GetClientForRepo(t *testing.T) { + // Create a test server that responds differently based on the request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this is an authenticated request + authHeader := r.Header.Get("Authorization") + + switch r.URL.Path { + case "/repos/owner/private-repo": + if authHeader != "" { + // Authenticated request to private repo succeeds + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"private-repo","private":true}`)) + } else { + // Unauthenticated request to private repo fails + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + } + case "/repos/owner/public-repo": + if authHeader != "" { + // Authenticated request to public repo fails with 403 + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message":"Resource not accessible by integration"}`)) + } else { + // Unauthenticated request to public repo succeeds + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"public-repo","private":false}`)) + } + case "/repos/owner/nonexistent-repo": + // Both authenticated and unauthenticated requests fail for nonexistent repo + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + default: + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + } + })) + defer server.Close() + + // Create an authenticated client + authClient := github.NewClient(nil) + authClient.BaseURL, _ = url.Parse(server.URL + "/") + authClient = authClient.WithAuthToken("test-token") + + // Create the factory + factory := NewRepoAwareClientFactory(authClient, 10*time.Millisecond) + + // Test private repository + client, err := factory.GetClientForRepo(context.Background(), "owner", "private-repo") + assert.NoError(t, err) + assert.Equal(t, authClient, client) + + // Test public repository + client, err = factory.GetClientForRepo(context.Background(), "owner", "public-repo") + assert.NoError(t, err) + // Should be a different client than the auth client + assert.NotEqual(t, authClient, client) + + // Test nonexistent repository + client, err = factory.GetClientForRepo(context.Background(), "owner", "nonexistent-repo") + assert.Error(t, err) + assert.Nil(t, client) + + // Test context-based client selection + ctx := WithRepoContext(context.Background(), "owner", "private-repo") + client, err = factory.GetClientFn()(ctx) + assert.NoError(t, err) + assert.Equal(t, authClient, client) + + ctx = WithRepoContext(context.Background(), "owner", "public-repo") + client, err = factory.GetClientFn()(ctx) + assert.NoError(t, err) + assert.NotEqual(t, authClient, client) + + // Test missing context info + ctx = context.Background() + client, err = factory.GetClientFn()(ctx) + assert.NoError(t, err) + assert.Equal(t, authClient, client) // Should default to auth client +} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 4403e2a19..765861cb0 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -190,6 +190,9 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( return mcp.NewToolResultError(err.Error()), nil } + // Add repository info to context for client selection + ctx = WithRepoContext(ctx, owner, repo) + opts := &github.BranchListOptions{ ListOptions: github.ListOptions{ Page: pagination.page, @@ -450,6 +453,9 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc return mcp.NewToolResultError(err.Error()), nil } + // Add repository info to context for client selection + ctx = WithRepoContext(ctx, owner, repo) + client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 949157f55..96843da5a 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -79,6 +79,9 @@ func RepositoryResourceContentsHandler(getClient GetClientFn) func(ctx context.C } repo := r[0] + // Add repository info to context for client selection + ctx = WithRepoContext(ctx, owner, repo) + // path should be a joined list of the path parts path := "" p, ok := request.Params.Arguments["path"].([]string) diff --git a/pkg/github/search.go b/pkg/github/search.go index ac5e2994c..4865b009a 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -36,6 +36,14 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewToolResultError(err.Error()), nil } + // Extract repository info if present in the query + // This is a simple heuristic to check if the query contains a specific repository + // Format could be "repo:owner/repo" or similar + repoQuery := extractRepoFromQuery(query) + if repoQuery.owner != "" && repoQuery.repo != "" { + ctx = WithRepoContext(ctx, repoQuery.owner, repoQuery.repo) + } + opts := &github.SearchOptions{ ListOptions: github.ListOptions{ Page: pagination.page, @@ -109,6 +117,12 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(err.Error()), nil } + // Extract repository info if present in the query + repoQuery := extractRepoFromQuery(query) + if repoQuery.owner != "" && repoQuery.repo != "" { + ctx = WithRepoContext(ctx, repoQuery.owner, repoQuery.repo) + } + opts := &github.SearchOptions{ Sort: sort, Order: order,