From f5f5487bf3e74494ff30e79721e6a7007ad14408 Mon Sep 17 00:00:00 2001 From: Newman Date: Tue, 22 Jul 2025 14:25:39 -0700 Subject: [PATCH 1/2] Add go-githubauth --- go.mod | 9 ++++++--- go.sum | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ab2302ed5..568160cbd 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,9 @@ require ( require ( github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect + github.com/google/go-github/v69 v69.2.0 // indirect + github.com/jferrl/go-githubauth v1.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect @@ -30,7 +33,7 @@ require ( github.com/google/go-github/v71 v71.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/mux v1.8.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -45,10 +48,10 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/time v0.6.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e7f6794a7..e7226d017 100644 --- a/go.sum +++ b/go.sum @@ -15,9 +15,13 @@ github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrK github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 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/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/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= github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= @@ -28,8 +32,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jferrl/go-githubauth v1.2.0 h1:K138gEpO2e/yBf6OI5Vb7+0xgZZa7N7/su/iAAG0ieU= +github.com/jferrl/go-githubauth v1.2.0/go.mod h1:mglSJcfvt4HSvuzQKYx4vkvi1PtlMj88m2gz660QuC0= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -98,6 +106,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -105,6 +115,8 @@ golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 13035424cce2fea94324bb956e1c9d69ed1dca0a Mon Sep 17 00:00:00 2001 From: Newman Date: Tue, 22 Jul 2025 14:29:00 -0700 Subject: [PATCH 2/2] Add http server with github app auth --- cmd/github-mcp-server/main.go | 68 +++++++++- internal/ghmcp/http_server.go | 229 ++++++++++++++++++++++++++++++++++ internal/ghmcp/server.go | 114 +++++++++++++---- 3 files changed, 386 insertions(+), 25 deletions(-) create mode 100644 internal/ghmcp/http_server.go diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index fb716f78d..091819986 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -58,6 +58,53 @@ var ( return ghmcp.RunStdioServer(stdioServerConfig) }, } + + httpCmd = &cobra.Command{ + Use: "http", + Short: "Start HTTP server", + Long: `Start a server that communicates via HTTP using the Streamable-HTTP transport.`, + RunE: func(_ *cobra.Command, _ []string) error { + // Check if we have either a personal access token or GitHub App credentials + token := viper.GetString("personal_access_token") + appID := viper.GetString("app_id") + appPrivateKey := viper.GetString("app_private_key") + enableGitHubAppAuth := viper.GetBool("enable_github_app_auth") + + if token == "" && (!enableGitHubAppAuth || appID == "" || appPrivateKey == "") { + return errors.New("either GITHUB_PERSONAL_ACCESS_TOKEN or GitHub App credentials (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY) must be set") + } + + // 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. + // https://github.com/spf13/viper/issues/380 + var enabledToolsets []string + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + + httpServerConfig := ghmcp.HttpServerConfig{ + Version: version, + Host: viper.GetString("host"), + Token: token, + EnabledToolsets: enabledToolsets, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + ReadOnly: viper.GetBool("read-only"), + ExportTranslations: viper.GetBool("export-translations"), + EnableCommandLogging: viper.GetBool("enable-command-logging"), + LogFilePath: viper.GetString("log-file"), + Address: viper.GetString("http_address"), + MCPPath: viper.GetString("http_mcp_path"), + EnableCORS: viper.GetBool("http_enable_cors"), + AppID: appID, + AppPrivateKey: appPrivateKey, + EnableGitHubAppAuth: enableGitHubAppAuth, + InstallationIDHeader: viper.GetString("installation_id_header"), + } + + return ghmcp.RunHTTPServer(httpServerConfig) + }, + } ) func init() { @@ -74,7 +121,18 @@ 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.)") - // Bind flag to viper + // GitHub App authentication flags + rootCmd.PersistentFlags().String("app-id", "", "GitHub App ID for authentication") + rootCmd.PersistentFlags().String("app-private-key", "", "GitHub App private key for authentication") + rootCmd.PersistentFlags().Bool("enable-github-app-auth", false, "Enable GitHub App authentication via custom headers") + rootCmd.PersistentFlags().String("installation-id-header", "X-GitHub-Installation-ID", "Custom header name to read installation ID from") + + // HTTP server specific flags + httpCmd.Flags().String("http-address", ":8080", "HTTP server address to bind to") + httpCmd.Flags().String("http-mcp-path", "/mcp", "HTTP path for MCP endpoint") + httpCmd.Flags().Bool("http-enable-cors", false, "Enable CORS for cross-origin requests") + + // Bind flags 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")) @@ -82,9 +140,17 @@ func init() { _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) + _ = viper.BindPFlag("app_id", rootCmd.PersistentFlags().Lookup("app-id")) + _ = viper.BindPFlag("app_private_key", rootCmd.PersistentFlags().Lookup("app-private-key")) + _ = viper.BindPFlag("enable_github_app_auth", rootCmd.PersistentFlags().Lookup("enable-github-app-auth")) + _ = viper.BindPFlag("installation_id_header", rootCmd.PersistentFlags().Lookup("installation-id-header")) + _ = viper.BindPFlag("http_address", httpCmd.Flags().Lookup("http-address")) + _ = viper.BindPFlag("http_mcp_path", httpCmd.Flags().Lookup("http-mcp-path")) + _ = viper.BindPFlag("http_enable_cors", httpCmd.Flags().Lookup("http-enable-cors")) // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(httpCmd) } func initConfig() { diff --git a/internal/ghmcp/http_server.go b/internal/ghmcp/http_server.go new file mode 100644 index 000000000..a5d9ddc05 --- /dev/null +++ b/internal/ghmcp/http_server.go @@ -0,0 +1,229 @@ +package ghmcp + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/server" + "github.com/sirupsen/logrus" +) + +type HttpServerConfig struct { + // Version of the server + Version string + + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string + + // GitHub Token to authenticate with the GitHub API + Token string + + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string + + // Whether to enable dynamic toolsets + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery + DynamicToolsets bool + + // ReadOnly indicates if we should only register read-only tools + ReadOnly bool + + // ExportTranslations indicates if we should export translations + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions + ExportTranslations bool + + // EnableCommandLogging indicates if we should log commands + EnableCommandLogging bool + + // Path to the log file if not stderr + LogFilePath string + + // HTTP server configuration + Address string + + // MCP endpoint path (defaults to "/mcp") + MCPPath string + + // Enable CORS for cross-origin requests + EnableCORS bool + + // GITHUB APP ID + AppID string + + // GITHUB APP PRIVATE KEY + AppPrivateKey string + + // Whether to enable GitHub App authentication via headers + EnableGitHubAppAuth bool + + // Custom header name to read installation ID from (defaults to "X-GitHub-Installation-ID") + InstallationIDHeader string +} + +const installationContextKey = "installation_id" + +// RunHTTPServer is not concurrent safe. +func RunHTTPServer(cfg HttpServerConfig) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, dumpTranslations := translations.TranslationHelper() + + mcpCfg := MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + AppID: cfg.AppID, + AppPrivateKey: cfg.AppPrivateKey, + EnableGitHubAppAuth: cfg.EnableGitHubAppAuth, + InstallationIDHeader: cfg.InstallationIDHeader, + } + + ghServer, err := NewMCPServer(mcpCfg) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + httpServer := server.NewStreamableHTTPServer(ghServer) + + logrusLogger := logrus.New() + if cfg.LogFilePath != "" { + file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + logrusLogger.SetLevel(logrus.DebugLevel) + logrusLogger.SetOutput(file) + } else { + logrusLogger.SetLevel(logrus.InfoLevel) + } + + if cfg.Address == "" { + cfg.Address = ":8080" + } + if cfg.MCPPath == "" { + cfg.MCPPath = "/mcp" + } + if cfg.InstallationIDHeader == "" { + cfg.InstallationIDHeader = "X-GitHub-Installation-ID" + } + + mux := http.NewServeMux() + var handler http.Handler = httpServer + + // Apply middlewares in the correct order: CORS first, then auth + if cfg.EnableCORS { + handler = corsMiddleware(handler) + } + if cfg.EnableGitHubAppAuth { + handler = authMiddleware(handler, cfg.InstallationIDHeader, logrusLogger) + } + + mux.Handle(cfg.MCPPath, handler) + + srv := &http.Server{ + Addr: cfg.Address, + Handler: mux, + } + + if cfg.ExportTranslations { + dumpTranslations() + } + + errC := make(chan error, 1) + go func() { + logrusLogger.Infof("Starting HTTP server on %s", cfg.Address) + logrusLogger.Infof("MCP endpoint available at http://localhost%s%s", cfg.Address, cfg.MCPPath) + if cfg.EnableGitHubAppAuth { + logrusLogger.Infof("GitHub App authentication enabled with header: %s", cfg.InstallationIDHeader) + } + errC <- srv.ListenAndServe() + }() + + _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on HTTP at %s\n", cfg.Address) + _, _ = fmt.Fprintf(os.Stderr, "MCP endpoint: http://localhost%s%s\n", cfg.Address, cfg.MCPPath) + if cfg.EnableGitHubAppAuth { + _, _ = fmt.Fprintf(os.Stderr, "GitHub App authentication enabled with header: %s\n", cfg.InstallationIDHeader) + } + + select { + case <-ctx.Done(): + logrusLogger.Infof("shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logrusLogger.Errorf("error during server shutdown: %v", err) + } + case err := <-errC: + if err != nil && err != http.ErrServerClosed { + return fmt.Errorf("error running server: %w", err) + } + } + + return nil +} + +// corsMiddleware adds CORS headers to allow cross-origin requests +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Accept-Encoding, Accept-Language, Cache-Control, Connection, Host, Origin, Referer, User-Agent") + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +// authMiddleware extracts installation IDs from custom headers and adds them to the request context +func authMiddleware(next http.Handler, headerName string, logger *logrus.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + installationIDStr := r.Header.Get(headerName) + if installationIDStr == "" { + next.ServeHTTP(w, r) + return + } + + installationID, err := strconv.ParseInt(installationIDStr, 10, 64) + if err != nil { + logger.Warnf("Invalid installation ID format in header %s", headerName) + http.Error(w, "Invalid installation ID format", http.StatusBadRequest) + return + } + + if installationID <= 0 { + logger.Warnf("Invalid installation ID value: %d", installationID) + http.Error(w, "Invalid installation ID value", http.StatusBadRequest) + return + } + + ctx := context.WithValue(r.Context(), installationContextKey, installationID) + r = r.WithContext(ctx) + + if logger.GetLevel() == logrus.DebugLevel { + logger.Debugf("Authenticated request with installation ID %d", installationID) + } else { + logger.Debug("Request authenticated with GitHub App installation") + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9a9c73926..ce5a898a7 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "os/signal" + "strconv" "strings" "syscall" @@ -16,10 +17,12 @@ import ( mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v72/github" + "github.com/jferrl/go-githubauth" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" "github.com/sirupsen/logrus" + "golang.org/x/oauth2" ) type MCPServerConfig struct { @@ -45,6 +48,18 @@ type MCPServerConfig struct { // Translator provides translated text for the server tooling Translator translations.TranslationHelperFunc + + // GITHUB APP ID + AppID string + + // GITHUB APP PRIVATE KEY + AppPrivateKey string + + // Whether to enable GitHub App authentication via headers + EnableGitHubAppAuth bool + + // Custom header name to read installation ID from (defaults to "X-GitHub-Installation-ID") + InstallationIDHeader string } func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { @@ -53,22 +68,46 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { return nil, fmt.Errorf("failed to parse API host: %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 + privateKey := []byte(cfg.AppPrivateKey) + appID, _ := strconv.ParseInt(cfg.AppID, 10, 64) + + // Set default header name if not provided + if cfg.InstallationIDHeader == "" { + cfg.InstallationIDHeader = "X-GitHub-Installation-ID" + } - // 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 GitHub App token source if enabled + var appTokenSource oauth2.TokenSource + if cfg.EnableGitHubAppAuth && cfg.AppID != "" && cfg.AppPrivateKey != "" { + var err error + appTokenSource, err = githubauth.NewApplicationTokenSource(appID, privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub App token source: %w", err) + } + } + + // Only create static clients if not using GitHub App auth + var restClient *gogithub.Client + var gqlClient *githubv4.Client + var gqlHTTPClient *http.Client + if !cfg.EnableGitHubAppAuth || appTokenSource == nil { + 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. + // Use static token + gqlHTTPClient = &http.Client{ + Transport: &bearerAuthTransport{ + transport: http.DefaultTransport, + token: cfg.Token, + }, + } + gqlClient = githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) + } // When a client send an initialize request, update the user agent to include the client info. beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) { @@ -79,11 +118,14 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { message.Params.ClientInfo.Version, ) - restClient.UserAgent = userAgent - - gqlHTTPClient.Transport = &userAgentTransport{ - transport: gqlHTTPClient.Transport, - agent: userAgent, + if restClient != nil { + restClient.UserAgent = userAgent + } + if gqlHTTPClient != nil { + gqlHTTPClient.Transport = &userAgentTransport{ + transport: gqlHTTPClient.Transport, + agent: userAgent, + } } } @@ -104,12 +146,36 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { } } - getClient := func(_ context.Context) (*gogithub.Client, error) { - return restClient, nil // closing over client + // Create dynamic client functions that can handle per-request authentication + getClient := func(ctx context.Context) (*gogithub.Client, error) { + if !cfg.EnableGitHubAppAuth || appTokenSource == nil { + return restClient, nil + } + installationID, ok := ctx.Value(installationContextKey).(int64) + if !ok || installationID <= 0 { + return restClient, nil + } + installationTokenSource := githubauth.NewInstallationTokenSource(installationID, appTokenSource) + oauth2Client := oauth2.NewClient(ctx, installationTokenSource) + restClient = gogithub.NewClient(oauth2Client) + restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + restClient.BaseURL = apiHost.baseRESTURL + restClient.UploadURL = apiHost.uploadURL + return restClient, nil } - getGQLClient := func(_ context.Context) (*githubv4.Client, error) { - return gqlClient, nil // closing over client + getGQLClient := func(ctx context.Context) (*githubv4.Client, error) { + if !cfg.EnableGitHubAppAuth || appTokenSource == nil { + return gqlClient, nil + } + installationID, ok := ctx.Value(installationContextKey).(int64) + if !ok || installationID <= 0 { + return gqlClient, nil + } + installationTokenSource := githubauth.NewInstallationTokenSource(installationID, appTokenSource) + oauth2Client := oauth2.NewClient(ctx, installationTokenSource) + gqlClient = githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), oauth2Client) + return gqlClient, nil } // Create default toolsets