diff --git a/Dockerfile b/Dockerfile index 92ed52581..741e83de0 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 64b68a37a..3e0096c12 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,249 @@ 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 +{ + "github.copilot.mcp.enabled": true, + "github.copilot.mcp.servers": { + "github-http": { + "type": "http", + "url": "http://localhost:8080", + "headers": { + "Authorization": "Bearer ${input:github_token}" + } + } + } +} +``` + +VS Code will prompt for the `github_token` input when connecting. + +For a remote server: + +```json +{ + "github.copilot.mcp.enabled": true, + "github.copilot.mcp.servers": { + "github-http": { + "type": "http", + "url": "https://your-mcp-server.example.com:8080", + "headers": { + "Authorization": "Bearer ${input:github_token}" + } + } + } +} +``` + +##### Claude Desktop + +Add the following to your Claude Desktop MCP configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows): + +```json +{ + "mcpServers": { + "github-http": { + "type": "http", + "url": "http://localhost:8080", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_TOKEN_HERE" + } + } + } +} +``` + +> **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 + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:8080"] + interval: 30s + timeout: 10s + retries: 3 +``` + +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**: + - Bind to localhost (`127.0.0.1`) for local-only access + - Use firewalls to restrict access to trusted networks + - 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 cfb68be4e..468e8a87d 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -88,6 +88,54 @@ 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"), + RepoAccessCacheTTL: &ttl, + } + return ghmcp.RunHTTPServer(httpServerConfig) + }, + } ) func init() { @@ -124,8 +172,13 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = 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/internal/ghmcp/server.go b/internal/ghmcp/server.go index b2e82f4e0..7d26a3acc 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -329,6 +329,48 @@ 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 + + // 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 @@ -689,3 +731,141 @@ 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 := req.Header.Get("Authorization") + if authHeader == "" { + return "" + } + + // Check for "Bearer " prefix + const bearerPrefix = "Bearer " + if strings.HasPrefix(authHeader, bearerPrefix) { + return strings.TrimPrefix(authHeader, bearerPrefix) + } + + return "" +} + +// 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, + }) + 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, + } + + // 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 +}