Skip to content

Add http server with github app auth #2

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 2 commits into from
Jul 22, 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
68 changes: 67 additions & 1 deletion cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -74,17 +121,36 @@ 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"))
_ = 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("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() {
Expand Down
9 changes: 6 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -98,13 +106,17 @@ 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=
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=
Expand Down
229 changes: 229 additions & 0 deletions internal/ghmcp/http_server.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy