Skip to content

Commit 1303542

Browse files
committed
Add http server with github app auth
1 parent f5f5487 commit 1303542

File tree

3 files changed

+386
-25
lines changed

3 files changed

+386
-25
lines changed

cmd/github-mcp-server/main.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,53 @@ var (
5858
return ghmcp.RunStdioServer(stdioServerConfig)
5959
},
6060
}
61+
62+
httpCmd = &cobra.Command{
63+
Use: "http",
64+
Short: "Start HTTP server",
65+
Long: `Start a server that communicates via HTTP using the Streamable-HTTP transport.`,
66+
RunE: func(_ *cobra.Command, _ []string) error {
67+
// Check if we have either a personal access token or GitHub App credentials
68+
token := viper.GetString("personal_access_token")
69+
appID := viper.GetString("app_id")
70+
appPrivateKey := viper.GetString("app_private_key")
71+
enableGitHubAppAuth := viper.GetBool("enable_github_app_auth")
72+
73+
if token == "" && (!enableGitHubAppAuth || appID == "" || appPrivateKey == "") {
74+
return errors.New("either GITHUB_PERSONAL_ACCESS_TOKEN or GitHub App credentials (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY) must be set")
75+
}
76+
77+
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
78+
// it's because viper doesn't handle comma-separated values correctly for env
79+
// vars when using GetStringSlice.
80+
// https://github.com/spf13/viper/issues/380
81+
var enabledToolsets []string
82+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
83+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
84+
}
85+
86+
httpServerConfig := ghmcp.HttpServerConfig{
87+
Version: version,
88+
Host: viper.GetString("host"),
89+
Token: token,
90+
EnabledToolsets: enabledToolsets,
91+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
92+
ReadOnly: viper.GetBool("read-only"),
93+
ExportTranslations: viper.GetBool("export-translations"),
94+
EnableCommandLogging: viper.GetBool("enable-command-logging"),
95+
LogFilePath: viper.GetString("log-file"),
96+
Address: viper.GetString("http_address"),
97+
MCPPath: viper.GetString("http_mcp_path"),
98+
EnableCORS: viper.GetBool("http_enable_cors"),
99+
AppID: appID,
100+
AppPrivateKey: appPrivateKey,
101+
EnableGitHubAppAuth: enableGitHubAppAuth,
102+
InstallationIDHeader: viper.GetString("installation_id_header"),
103+
}
104+
105+
return ghmcp.RunHTTPServer(httpServerConfig)
106+
},
107+
}
61108
)
62109

63110
func init() {
@@ -74,17 +121,36 @@ func init() {
74121
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
75122
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
76123

77-
// Bind flag to viper
124+
// GitHub App authentication flags
125+
rootCmd.PersistentFlags().String("app-id", "", "GitHub App ID for authentication")
126+
rootCmd.PersistentFlags().String("app-private-key", "", "GitHub App private key for authentication")
127+
rootCmd.PersistentFlags().Bool("enable-github-app-auth", false, "Enable GitHub App authentication via custom headers")
128+
rootCmd.PersistentFlags().String("installation-id-header", "X-GitHub-Installation-ID", "Custom header name to read installation ID from")
129+
130+
// HTTP server specific flags
131+
httpCmd.Flags().String("http-address", ":8080", "HTTP server address to bind to")
132+
httpCmd.Flags().String("http-mcp-path", "/mcp", "HTTP path for MCP endpoint")
133+
httpCmd.Flags().Bool("http-enable-cors", false, "Enable CORS for cross-origin requests")
134+
135+
// Bind flags to viper
78136
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
79137
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
80138
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
81139
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
82140
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
83141
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
84142
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
143+
_ = viper.BindPFlag("app_id", rootCmd.PersistentFlags().Lookup("app-id"))
144+
_ = viper.BindPFlag("app_private_key", rootCmd.PersistentFlags().Lookup("app-private-key"))
145+
_ = viper.BindPFlag("enable_github_app_auth", rootCmd.PersistentFlags().Lookup("enable-github-app-auth"))
146+
_ = viper.BindPFlag("installation_id_header", rootCmd.PersistentFlags().Lookup("installation-id-header"))
147+
_ = viper.BindPFlag("http_address", httpCmd.Flags().Lookup("http-address"))
148+
_ = viper.BindPFlag("http_mcp_path", httpCmd.Flags().Lookup("http-mcp-path"))
149+
_ = viper.BindPFlag("http_enable_cors", httpCmd.Flags().Lookup("http-enable-cors"))
85150

86151
// Add subcommands
87152
rootCmd.AddCommand(stdioCmd)
153+
rootCmd.AddCommand(httpCmd)
88154
}
89155

90156
func initConfig() {

internal/ghmcp/http_server.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package ghmcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"os/signal"
9+
"strconv"
10+
"syscall"
11+
"time"
12+
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/mark3labs/mcp-go/server"
15+
"github.com/sirupsen/logrus"
16+
)
17+
18+
type HttpServerConfig struct {
19+
// Version of the server
20+
Version string
21+
22+
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
23+
Host string
24+
25+
// GitHub Token to authenticate with the GitHub API
26+
Token string
27+
28+
// EnabledToolsets is a list of toolsets to enable
29+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
30+
EnabledToolsets []string
31+
32+
// Whether to enable dynamic toolsets
33+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
34+
DynamicToolsets bool
35+
36+
// ReadOnly indicates if we should only register read-only tools
37+
ReadOnly bool
38+
39+
// ExportTranslations indicates if we should export translations
40+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
41+
ExportTranslations bool
42+
43+
// EnableCommandLogging indicates if we should log commands
44+
EnableCommandLogging bool
45+
46+
// Path to the log file if not stderr
47+
LogFilePath string
48+
49+
// HTTP server configuration
50+
Address string
51+
52+
// MCP endpoint path (defaults to "/mcp")
53+
MCPPath string
54+
55+
// Enable CORS for cross-origin requests
56+
EnableCORS bool
57+
58+
// GITHUB APP ID
59+
AppID string
60+
61+
// GITHUB APP PRIVATE KEY
62+
AppPrivateKey string
63+
64+
// Whether to enable GitHub App authentication via headers
65+
EnableGitHubAppAuth bool
66+
67+
// Custom header name to read installation ID from (defaults to "X-GitHub-Installation-ID")
68+
InstallationIDHeader string
69+
}
70+
71+
const installationContextKey = "installation_id"
72+
73+
// RunHTTPServer is not concurrent safe.
74+
func RunHTTPServer(cfg HttpServerConfig) error {
75+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
76+
defer stop()
77+
78+
t, dumpTranslations := translations.TranslationHelper()
79+
80+
mcpCfg := MCPServerConfig{
81+
Version: cfg.Version,
82+
Host: cfg.Host,
83+
Token: cfg.Token,
84+
EnabledToolsets: cfg.EnabledToolsets,
85+
DynamicToolsets: cfg.DynamicToolsets,
86+
ReadOnly: cfg.ReadOnly,
87+
Translator: t,
88+
AppID: cfg.AppID,
89+
AppPrivateKey: cfg.AppPrivateKey,
90+
EnableGitHubAppAuth: cfg.EnableGitHubAppAuth,
91+
InstallationIDHeader: cfg.InstallationIDHeader,
92+
}
93+
94+
ghServer, err := NewMCPServer(mcpCfg)
95+
if err != nil {
96+
return fmt.Errorf("failed to create MCP server: %w", err)
97+
}
98+
99+
httpServer := server.NewStreamableHTTPServer(ghServer)
100+
101+
logrusLogger := logrus.New()
102+
if cfg.LogFilePath != "" {
103+
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
104+
if err != nil {
105+
return fmt.Errorf("failed to open log file: %w", err)
106+
}
107+
logrusLogger.SetLevel(logrus.DebugLevel)
108+
logrusLogger.SetOutput(file)
109+
} else {
110+
logrusLogger.SetLevel(logrus.InfoLevel)
111+
}
112+
113+
if cfg.Address == "" {
114+
cfg.Address = ":8080"
115+
}
116+
if cfg.MCPPath == "" {
117+
cfg.MCPPath = "/mcp"
118+
}
119+
if cfg.InstallationIDHeader == "" {
120+
cfg.InstallationIDHeader = "X-GitHub-Installation-ID"
121+
}
122+
123+
mux := http.NewServeMux()
124+
var handler http.Handler = httpServer
125+
126+
// Apply middlewares in the correct order: CORS first, then auth
127+
if cfg.EnableCORS {
128+
handler = corsMiddleware(handler)
129+
}
130+
if cfg.EnableGitHubAppAuth {
131+
handler = authMiddleware(handler, cfg.InstallationIDHeader, logrusLogger)
132+
}
133+
134+
mux.Handle(cfg.MCPPath, handler)
135+
136+
srv := &http.Server{
137+
Addr: cfg.Address,
138+
Handler: mux,
139+
}
140+
141+
if cfg.ExportTranslations {
142+
dumpTranslations()
143+
}
144+
145+
errC := make(chan error, 1)
146+
go func() {
147+
logrusLogger.Infof("Starting HTTP server on %s", cfg.Address)
148+
logrusLogger.Infof("MCP endpoint available at http://localhost%s%s", cfg.Address, cfg.MCPPath)
149+
if cfg.EnableGitHubAppAuth {
150+
logrusLogger.Infof("GitHub App authentication enabled with header: %s", cfg.InstallationIDHeader)
151+
}
152+
errC <- srv.ListenAndServe()
153+
}()
154+
155+
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on HTTP at %s\n", cfg.Address)
156+
_, _ = fmt.Fprintf(os.Stderr, "MCP endpoint: http://localhost%s%s\n", cfg.Address, cfg.MCPPath)
157+
if cfg.EnableGitHubAppAuth {
158+
_, _ = fmt.Fprintf(os.Stderr, "GitHub App authentication enabled with header: %s\n", cfg.InstallationIDHeader)
159+
}
160+
161+
select {
162+
case <-ctx.Done():
163+
logrusLogger.Infof("shutting down server...")
164+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
165+
defer cancel()
166+
if err := srv.Shutdown(shutdownCtx); err != nil {
167+
logrusLogger.Errorf("error during server shutdown: %v", err)
168+
}
169+
case err := <-errC:
170+
if err != nil && err != http.ErrServerClosed {
171+
return fmt.Errorf("error running server: %w", err)
172+
}
173+
}
174+
175+
return nil
176+
}
177+
178+
// corsMiddleware adds CORS headers to allow cross-origin requests
179+
func corsMiddleware(next http.Handler) http.Handler {
180+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181+
// Set CORS headers
182+
w.Header().Set("Access-Control-Allow-Origin", "*")
183+
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
184+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Accept-Encoding, Accept-Language, Cache-Control, Connection, Host, Origin, Referer, User-Agent")
185+
186+
// Handle preflight requests
187+
if r.Method == "OPTIONS" {
188+
w.WriteHeader(http.StatusOK)
189+
return
190+
}
191+
192+
next.ServeHTTP(w, r)
193+
})
194+
}
195+
196+
// authMiddleware extracts installation IDs from custom headers and adds them to the request context
197+
func authMiddleware(next http.Handler, headerName string, logger *logrus.Logger) http.Handler {
198+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
199+
installationIDStr := r.Header.Get(headerName)
200+
if installationIDStr == "" {
201+
next.ServeHTTP(w, r)
202+
return
203+
}
204+
205+
installationID, err := strconv.ParseInt(installationIDStr, 10, 64)
206+
if err != nil {
207+
logger.Warnf("Invalid installation ID format in header %s", headerName)
208+
http.Error(w, "Invalid installation ID format", http.StatusBadRequest)
209+
return
210+
}
211+
212+
if installationID <= 0 {
213+
logger.Warnf("Invalid installation ID value: %d", installationID)
214+
http.Error(w, "Invalid installation ID value", http.StatusBadRequest)
215+
return
216+
}
217+
218+
ctx := context.WithValue(r.Context(), installationContextKey, installationID)
219+
r = r.WithContext(ctx)
220+
221+
if logger.GetLevel() == logrus.DebugLevel {
222+
logger.Debugf("Authenticated request with installation ID %d", installationID)
223+
} else {
224+
logger.Debug("Request authenticated with GitHub App installation")
225+
}
226+
227+
next.ServeHTTP(w, r)
228+
})
229+
}

0 commit comments

Comments
 (0)