diff --git a/Dockerfile b/Dockerfile index f804c03aa..75a680090 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ FROM gcr.io/distroless/base-debian12 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" +# Expose port 8080 for HTTP mode +EXPOSE 8080 + # Set the working directory WORKDIR /server # Copy the binary from the build stage diff --git a/README.md b/README.md index afe003002..60dafcbe7 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,215 @@ the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data r } ``` +### HTTP Server Mode + +The GitHub MCP Server supports HTTP mode for serving multiple concurrent clients with per-request authentication. This is ideal for enterprise deployments where a centralized MCP server serves multiple users or applications. + +#### Starting the HTTP Server + +Start the HTTP server with the `http` command: + +```bash +# Start HTTP server on default port (8080) +github-mcp-server http + +# Start HTTP server on custom port +github-mcp-server http --port 3000 + +# With Docker +docker run -p 8080:8080 ghcr.io/github/github-mcp-server http + +# With Docker on custom port +docker run -p 3000:3000 ghcr.io/github/github-mcp-server http --port 3000 +``` + +> **Note:** Unlike stdio mode, HTTP mode does not require a `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable at startup. Instead, each client provides their token via the `Authorization` header. +#### Authentication with Authorization Header + +Clients authenticate by including their GitHub Personal Access Token in the `Authorization` header of each request: + +``` +Authorization: Bearer ghp_your_github_token_here +``` + +This "Bring Your Own Token" (BYOT) approach enables: +- **Multi-tenancy**: Different users can use their own tokens with proper permissions +- **Security**: Tokens are never stored on the server +- **Flexibility**: Users can revoke/rotate tokens independently + +#### Client Configuration Examples + +##### VS Code with GitHub Copilot + +Configure VS Code to connect to your HTTP server by adding the following to your VS Code MCP settings (`.vscode/settings.json` or user settings): + +```json +{ + "servers": { + "github-http": { + "type": "http", + "url": "http://your-mcp-server.example.com:8080", + "headers": { + "Authorization": "Bearer ${input:github_token}" + } + } + }, + "inputs": [ + { + "type": "promptString", + "id": "github_token", + "description": "GitHub Personal Access Token", + "password": true + } + ] +} +``` + +VS Code will prompt for the `github_token` input when connecting. + + + +> **Security Note:** When using hardcoded tokens in configuration files, ensure proper file permissions (e.g., `chmod 600`) to protect your token. +##### Other MCP Clients + +For other MCP clients that support HTTP transport, ensure they: +1. Connect to the server's HTTP endpoint (e.g., `http://localhost:8080`) +2. Include the `Authorization: Bearer ` header in all requests +3. Use the MCP streamable HTTP transport protocol + +Example with curl for testing: + +```bash +# Test server health (this should fail without proper MCP request structure) +curl -H "Authorization: Bearer ghp_your_token" http://localhost:8080 + +# Proper MCP client implementation required for actual tool calls +``` + +#### Docker Deployment + +##### Basic HTTP Server + +Run the HTTP server in Docker with port mapping: + +```bash +docker run -d \ + --name github-mcp-http \ + -p 8080:8080 \ + ghcr.io/github/github-mcp-server http +``` + +##### With Logging + +Enable file logging for debugging: + +```bash +docker run -d \ + --name github-mcp-http \ + -p 8080:8080 \ + -v $(pwd)/logs:/logs \ + ghcr.io/github/github-mcp-server http --log-file /logs/server.log +``` + +##### With Custom Configuration + +Use additional flags for configuration: + +```bash +docker run -d \ + --name github-mcp-http \ + -p 8080:8080 \ + ghcr.io/github/github-mcp-server http \ + --port 8080 \ + --toolsets actions,issues,pull_requests \ + --read-only \ + --log-file /var/log/github-mcp.log +``` + +##### Production Deployment with Docker Compose + +Create a `docker-compose.yml` file: + +```yaml +version: '3.8' +services: + github-mcp-server: + image: ghcr.io/github/github-mcp-server + command: http --port 8080 --log-file /logs/server.log + ports: + - "8080:8080" + volumes: + - ./logs:/logs + restart: unless-stopped + # Configure health checks at your load balancer or orchestrator level. +``` +Then start with: +```bash +docker-compose up -d +``` + +#### GitHub Enterprise Support + +HTTP mode works with GitHub Enterprise Server and GitHub Enterprise Cloud with data residency: + +```bash +# GitHub Enterprise Server +docker run -d \ + -p 8080:8080 \ + ghcr.io/github/github-mcp-server http \ + --gh-host https://github.yourcompany.com \ + --port 8080 + +# GitHub Enterprise Cloud with data residency +docker run -d \ + -p 8080:8080 \ + ghcr.io/github/github-mcp-server http \ + --gh-host https://octocorp.ghe.com \ + --port 8080 +``` + +Clients still provide their tokens via the `Authorization` header. + +#### Security Considerations + +When deploying the HTTP server: + +1. **Use HTTPS in Production**: Always use a reverse proxy (nginx, Caddy, etc.) to terminate TLS +2. **Network Security**: + - The HTTP server listens on all interfaces; restrict exposure using your deployment configuration (Docker port mappings, reverse proxy bind address, and host firewalls) + - For local-only access, publish the container/host port only on localhost (for Docker: `-p 127.0.0.1:8080:8080`) or bind your reverse proxy to `127.0.0.1` + - Consider VPN or IP allowlisting for remote deployments +3. **Token Management**: + - Tokens are validated per-request and never stored + - Use fine-grained tokens with minimum required permissions + - Rotate tokens regularly +4. **Rate Limiting**: Consider adding rate limiting at the reverse proxy level +5. **Monitoring**: Enable logging to track usage and potential security issues + +#### Troubleshooting HTTP Mode + +**Server won't start:** +- Check if port 8080 (or your custom port) is already in use +- Ensure Docker port mapping is correct (`-p host_port:container_port`) + +**Client connection fails:** +- Verify the server is running: `curl http://localhost:8080` (should return an error but connect) +- Check firewall rules allow connections to the port +- Verify the URL in client configuration matches the server address + +**Authentication errors:** +- Ensure the `Authorization` header is properly formatted: `Bearer ` +- Verify the GitHub token is valid and not expired +- Check token has required permissions for the operations being performed + +**Enable debug logging:** +```bash +github-mcp-server http --log-file debug.log +# Or with Docker: +docker run -p 8080:8080 -v $(pwd):/logs \ + ghcr.io/github/github-mcp-server http --log-file /logs/debug.log +``` + ## Installation ### Install in GitHub Copilot on VS Code diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index c361a4d5a..581a16027 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -89,6 +89,55 @@ var ( return ghmcp.RunStdioServer(stdioServerConfig) }, } + + httpCmd = &cobra.Command{ + Use: "http", + Short: "Start HTTP server", + Long: `Start an HTTP server that supports multiple concurrent clients with per-request authentication.`, + RunE: func(_ *cobra.Command, _ []string) error { + // Parse toolsets + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + + // Parse tools + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + // Parse enabled features + var enabledFeatures []string + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } + } + + ttl := viper.GetDuration("repo-access-cache-ttl") + httpServerConfig := ghmcp.HTTPServerConfig{ + Version: version, + Host: viper.GetString("host"), + Port: viper.GetInt("port"), + EnabledToolsets: enabledToolsets, + EnabledTools: enabledTools, + EnabledFeatures: enabledFeatures, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + ReadOnly: viper.GetBool("read-only"), + LogFilePath: viper.GetString("log-file"), + ContentWindowSize: viper.GetInt("content-window-size"), + LockdownMode: viper.GetBool("lockdown-mode"), + InsidersMode: viper.GetBool("insiders"), + RepoAccessCacheTTL: &ttl, + } + return ghmcp.RunHTTPServer(httpServerConfig) + }, + } ) func init() { @@ -127,8 +176,13 @@ func init() { _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + // Add HTTP-specific flags + httpCmd.Flags().Int("port", 8080, "Port to listen on for HTTP server") + _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) + // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(httpCmd) } func initConfig() { diff --git a/go.mod b/go.mod index 10bbde9d1..83cd80561 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,20 @@ require ( require ( github.com/aymerick/douceur v0.2.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/swag v0.21.1 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.38.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) + +require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 37aabb0a6..b03625c4c 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -338,6 +338,51 @@ type StdioServerConfig struct { RepoAccessCacheTTL *time.Duration } +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 + + // Port to listen on + Port int + + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string + + // EnabledTools is a list of specific tools to enable (additive to toolsets) + // When specified, these tools are registered in addition to any specified toolset tools + EnabledTools []string + + // EnabledFeatures is a list of feature flags that are enabled + // Items with FeatureFlagEnable matching an entry in this list will be available + EnabledFeatures []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 + + // Path to the log file if not stderr + LogFilePath string + + // Content window size + ContentWindowSize int + + // LockdownMode indicates if we should enable lockdown mode + LockdownMode bool + + // InsidersMode indicates if we should enable experimental features + InsidersMode bool + + // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. + RepoAccessCacheTTL *time.Duration +} + // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { // Create app context @@ -693,3 +738,154 @@ func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, return fetcher.FetchTokenScopes(ctx, token) } + +// extractTokenFromAuthHeader extracts a GitHub token from the Authorization header. +// It supports "Bearer " format. +func extractTokenFromAuthHeader(req *http.Request) string { + authHeader := strings.TrimSpace(req.Header.Get("Authorization")) + if authHeader == "" { + return "" + } + + // Split into scheme and credentials on general whitespace, and compare scheme case-insensitively. + parts := strings.Fields(authHeader) + if len(parts) != 2 { + return "" + } + + if !strings.EqualFold(parts[0], "Bearer") { + return "" + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return "" + } + + return token +} + +// RunHTTPServer starts the HTTP server for multi-client MCP connections. +func RunHTTPServer(cfg HTTPServerConfig) error { + // Create app context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, _ := translations.TranslationHelper() + + // Set up logging + var slogHandler slog.Handler + var logOutput io.Writer + 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) + } + defer file.Close() + logOutput = file + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug}) + } else { + logOutput = os.Stderr + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) + } + logger := slog.New(slogHandler) + logger.Info("starting HTTP server", + "version", cfg.Version, + "host", cfg.Host, + "port", cfg.Port, + "dynamicToolsets", cfg.DynamicToolsets, + "readOnly", cfg.ReadOnly, + "lockdownEnabled", cfg.LockdownMode) + + // Create HTTP handler with per-request server creation + handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + // Extract token from Authorization header + token := extractTokenFromAuthHeader(req) + if token == "" { + logger.Warn("request without valid Authorization header", "path", req.URL.Path) + return nil // This will cause a 400 Bad Request + } + + // Fetch token scopes for scope-based tool filtering (PAT tokens only) + var tokenScopes []string + if strings.HasPrefix(token, "ghp_") { + fetchedScopes, err := fetchTokenScopesForHost(req.Context(), token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Debug("token scopes fetched for filtering", "scopes", tokenScopes) + } + } + + // Create a new server instance for this request with the extracted token + ghServer, err := NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: token, + EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + LockdownMode: cfg.LockdownMode, + Logger: logger, + RepoAccessTTL: cfg.RepoAccessCacheTTL, + TokenScopes: tokenScopes, + InsidersMode: cfg.InsidersMode, + }) + if err != nil { + logger.Error("failed to create MCP server", "error", err) + return nil + } + + return ghServer + }, &mcp.StreamableHTTPOptions{ + Logger: logger, + SessionTimeout: 30 * time.Second, // 30 second heartbeat interval + }) + + // Create HTTP server + addr := fmt.Sprintf(":%d", cfg.Port) + httpServer := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Start server in goroutine + errC := make(chan error, 1) + go func() { + logger.Info("HTTP server listening", "addr", addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errC <- fmt.Errorf("HTTP server error: %w", err) + } + }() + + // Wait for shutdown signal + select { + case <-ctx.Done(): + logger.Info("shutting down HTTP server", "signal", "context done") + case err := <-errC: + if err != nil { + logger.Error("HTTP server error", "error", err) + return err + } + } + + // Graceful shutdown + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + logger.Error("error during HTTP server shutdown", "error", err) + return fmt.Errorf("HTTP server shutdown error: %w", err) + } + + logger.Info("HTTP server stopped") + return nil +} diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index 2139aa280..b6a588c0a 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -1,6 +1,7 @@ package ghmcp import ( + "net/http" "testing" "github.com/github/github-mcp-server/pkg/translations" @@ -111,3 +112,136 @@ func TestResolveEnabledToolsets(t *testing.T) { }) } } + +func Test_extractTokenFromAuthHeader(t *testing.T) { + t.Parallel() + + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + req *http.Request + want string + }{ + { + name: "valid bearer token", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"Bearer ghp_1234567890abcdef"}, + }, + }, + want: "ghp_1234567890abcdef", + }, + { + name: "bearer token with extra whitespace", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{" Bearer ghp_token123 "}, + }, + }, + want: "ghp_token123", + }, + { + name: "case insensitive bearer", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"bearer ghp_lowercase"}, + }, + }, + want: "ghp_lowercase", + }, + { + name: "mixed case bearer", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"BeArEr ghp_mixedcase"}, + }, + }, + want: "ghp_mixedcase", + }, + { + name: "no authorization header", + req: &http.Request{ + Header: http.Header{}, + }, + want: "", + }, + { + name: "empty authorization header", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{""}, + }, + }, + want: "", + }, + { + name: "whitespace only authorization header", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{" "}, + }, + }, + want: "", + }, + { + name: "missing token after bearer", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"Bearer"}, + }, + }, + want: "", + }, + { + name: "bearer with only whitespace token", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"Bearer "}, + }, + }, + want: "", + }, + { + name: "non-bearer scheme", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"Basic dXNlcjpwYXNz"}, + }, + }, + want: "", + }, + { + name: "no space between bearer and token", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"Bearerghp_token"}, + }, + }, + want: "", + }, + { + name: "token only without scheme", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"ghp_token_only"}, + }, + }, + want: "", + }, + { + name: "multiple spaces between bearer and token", + req: &http.Request{ + Header: http.Header{ + "Authorization": []string{"Bearer ghp_multispace"}, + }, + }, + want: "ghp_multispace", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractTokenFromAuthHeader(tt.req) + assert.Equal(t, tt.want, got) + }) + } +}