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)
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