Skip to content

Commit 1928803

Browse files
committed
feat: add HTTP server support with OAuth token authentication and health check endpoint
1 parent 1575074 commit 1928803

File tree

10 files changed

+658
-43
lines changed

10 files changed

+658
-43
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
The primary entrypoint lives in `cmd/github-mcp-server/main.go`; `generate_docs.go` updates documentation and `cmd/mcpcurl` offers a lightweight client. Shared logic lives under `pkg/` (tool handlers, toolsets, translations) while internal-only helpers stay in `internal/` for server wiring and schema snapshots. `e2e/` holds black-box tests, `docs/` tracks installation and policy guides, and `script/` automates linting, docs, and release chores.
55

66
## Build, Test, and Development Commands
7-
`go build ./cmd/github-mcp-server` produces the local server binary. `go test -v ./...` runs unit and snapshot suites; use `script/test` when you need the race detector. `script/lint` wraps the required `golangci-lint run` configuration. Regenerate published docs with `script/generate-docs`, and probe tools by piping JSON-RPC into `go run ./cmd/github-mcp-server main.go stdio` as shown in `script/get-me`.
7+
`go build ./cmd/github-mcp-server` produces the local server binary. `go test -v ./...` runs unit and snapshot suites; use `script/test` when you need the race detector. `script/lint` wraps the required `golangci-lint run` configuration. Regenerate published docs with `script/generate-docs`, and probe tools by piping JSON-RPC into `go run ./cmd/github-mcp-server main.go stdio` as shown in `script/get-me`. Launch the HTTP transport with `github-mcp-server http --listen :8080` and authenticate requests with an `Authorization: Bearer <token>` header if you are fronting the server with OAuth-aware infrastructure like Pomerium.
88

99
## Coding Style & Naming Conventions
1010
Format Go code with `gofmt` (tabs for indentation) and keep imports tidy via `goimports` or the editor equivalent. Follow the active `golangci-lint` ruleset (bodyclose, gosec, revive, etc.) and prefer explicit error handling. Tool identifiers exposed through MCP stay snake_case (e.g., `list_discussions`), while exported Go symbols remain PascalCase.

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,33 @@ If you don't have Docker, you can use `go build` to build the binary in the
265265
}
266266
```
267267

268+
### Run as an HTTP server
269+
270+
The same binary can expose the MCP server over HTTP using the streamable transport. This mode is designed for deployments behind zero-trust proxies (for example, Pomerium) that exchange OAuth tokens with GitHub on a per-request basis.
271+
272+
```bash
273+
github-mcp-server http \
274+
--listen :8080 \
275+
--http-path /mcp \
276+
--health-path /health
277+
```
278+
279+
- The `/health` endpoint is public and returns `200 OK` to signal readiness.
280+
- The MCP endpoint (default `/mcp`) requires every request to include an `Authorization: Bearer <token>` header carrying a GitHub OAuth access token. Tokens are not cached between requests.
281+
- Customize listening address, path, and graceful shutdown timeout via `--listen`, `--http-path`, `--health-path`, and `--shutdown-timeout`.
282+
283+
For example, when testing locally with an OAuth token stored in `$GITHUB_OAUTH_TOKEN`:
284+
285+
```bash
286+
curl -X POST \
287+
-H "Content-Type: application/json" \
288+
-H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \
289+
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' \
290+
http://localhost:8080/mcp
291+
```
292+
293+
See [docs/pomerium-http-example.md](docs/pomerium-http-example.md) for a reference configuration that forwards GitHub OAuth tokens from Pomerium to the server.
294+
268295
## Tool Configuration
269296

270297
The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size.

cmd/github-mcp-server/main.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"strings"
8+
"time"
89

910
"github.com/github/github-mcp-server/internal/ghmcp"
1011
"github.com/github/github-mcp-server/pkg/github"
@@ -60,6 +61,33 @@ var (
6061
return ghmcp.RunStdioServer(stdioServerConfig)
6162
},
6263
}
64+
65+
httpCmd = &cobra.Command{
66+
Use: "http",
67+
Short: "Start HTTP server",
68+
Long: `Start a server that communicates over HTTP using the streamable transport.`,
69+
RunE: func(_ *cobra.Command, _ []string) error {
70+
var enabledToolsets []string
71+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
72+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
73+
}
74+
75+
httpServerConfig := ghmcp.HTTPServerConfig{
76+
Version: version,
77+
Host: viper.GetString("host"),
78+
EnabledToolsets: enabledToolsets,
79+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
80+
ReadOnly: viper.GetBool("read-only"),
81+
ContentWindowSize: viper.GetInt("content-window-size"),
82+
ListenAddress: viper.GetString("listen-address"),
83+
EndpointPath: viper.GetString("http-path"),
84+
HealthPath: viper.GetString("health-path"),
85+
ShutdownTimeout: viper.GetDuration("shutdown-timeout"),
86+
LogFilePath: viper.GetString("log-file"),
87+
}
88+
return ghmcp.RunHTTPServer(httpServerConfig)
89+
},
90+
}
6391
)
6492

6593
func init() {
@@ -88,8 +116,19 @@ func init() {
88116
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
89117
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
90118

119+
httpCmd.Flags().String("listen", ":8080", "Address for the HTTP server to listen on")
120+
httpCmd.Flags().String("http-path", "/mcp", "HTTP path for MCP requests")
121+
httpCmd.Flags().String("health-path", "/health", "HTTP path for health checks")
122+
httpCmd.Flags().Duration("shutdown-timeout", 10*time.Second, "Graceful shutdown timeout for the HTTP server")
123+
124+
_ = viper.BindPFlag("listen-address", httpCmd.Flags().Lookup("listen"))
125+
_ = viper.BindPFlag("http-path", httpCmd.Flags().Lookup("http-path"))
126+
_ = viper.BindPFlag("health-path", httpCmd.Flags().Lookup("health-path"))
127+
_ = viper.BindPFlag("shutdown-timeout", httpCmd.Flags().Lookup("shutdown-timeout"))
128+
91129
// Add subcommands
92130
rootCmd.AddCommand(stdioCmd)
131+
rootCmd.AddCommand(httpCmd)
93132
}
94133

95134
func initConfig() {

docs/pomerium-http-example.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Deploying GitHub MCP Server behind Pomerium
2+
3+
This example demonstrates how to run the GitHub MCP Server in HTTP mode behind [Pomerium](https://www.pomerium.com/), forwarding GitHub OAuth tokens to the server on each request.
4+
5+
1. Start the server in HTTP mode:
6+
7+
```bash
8+
github-mcp-server http \
9+
--listen :8080 \
10+
--http-path /mcp \
11+
--health-path /health
12+
```
13+
14+
2. Configure Pomerium to authenticate users with GitHub and pass the resulting access token to the upstream via the `Authorization` header. A simplified route looks like:
15+
16+
```yaml
17+
routes:
18+
- from: https://mcp.example.com
19+
to: http://github-mcp-server:8080
20+
preserve_host_header: true
21+
22+
enable_google_cloud_serverless_authentication: false
23+
pass_identity_headers: true
24+
25+
# Forward OAuth tokens from GitHub to the MCP server
26+
set_request_headers:
27+
Authorization: "Bearer {{ .Pomerium.JWT.OAuth.AccessToken }}"
28+
29+
upstream_oauth2:
30+
client_id: ${GITHUB_OAUTH_CLIENT_ID}
31+
client_secret: ${GITHUB_OAUTH_CLIENT_SECRET}
32+
scopes:
33+
- read:user
34+
- user:email
35+
- repo
36+
- read:org
37+
endpoint:
38+
auth_url: https://github.com/login/oauth/authorize
39+
token_url: https://github.com/login/oauth/access_token
40+
```
41+
42+
3. Point your MCP host at `https://mcp.example.com/mcp` and omit the static `GITHUB_PERSONAL_ACCESS_TOKEN`. Each request will be authenticated with the user’s GitHub OAuth token issued by Pomerium.
43+
44+
Refer to [Pomerium's MCP documentation](https://www.pomerium.com/docs/capabilities/mcp) for deployment details and advanced routing options.

internal/ghmcp/http_server.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package ghmcp
2+
3+
import (
4+
"context"
5+
stdErrors "errors"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"os"
11+
"os/signal"
12+
"strings"
13+
"syscall"
14+
"time"
15+
16+
pkgErrors "github.com/github/github-mcp-server/pkg/errors"
17+
"github.com/github/github-mcp-server/pkg/translations"
18+
"github.com/mark3labs/mcp-go/server"
19+
)
20+
21+
// HTTPServerConfig captures configuration for the HTTP transport.
22+
type HTTPServerConfig struct {
23+
Version string
24+
Host string
25+
EnabledToolsets []string
26+
DynamicToolsets bool
27+
ReadOnly bool
28+
ContentWindowSize int
29+
ListenAddress string
30+
EndpointPath string
31+
HealthPath string
32+
ShutdownTimeout time.Duration
33+
LogFilePath string
34+
}
35+
36+
const (
37+
defaultHTTPListenAddress = ":8080"
38+
defaultHTTPEndpoint = "/mcp"
39+
defaultHTTPHealthPath = "/health"
40+
defaultShutdownTimeout = 10 * time.Second
41+
)
42+
43+
// RunHTTPServer starts an MCP server over the Streamable HTTP transport.
44+
func RunHTTPServer(cfg HTTPServerConfig) error {
45+
listenAddress := cfg.ListenAddress
46+
if strings.TrimSpace(listenAddress) == "" {
47+
listenAddress = defaultHTTPListenAddress
48+
}
49+
50+
endpointPath := normalizePath(cfg.EndpointPath, defaultHTTPEndpoint)
51+
healthPath := normalizePath(cfg.HealthPath, defaultHTTPHealthPath)
52+
53+
shutdownTimeout := cfg.ShutdownTimeout
54+
if shutdownTimeout <= 0 {
55+
shutdownTimeout = defaultShutdownTimeout
56+
}
57+
58+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
59+
defer stop()
60+
61+
translator, _ := translations.TranslationHelper()
62+
63+
ghServer, err := NewMCPServer(MCPServerConfig{
64+
Version: cfg.Version,
65+
Host: cfg.Host,
66+
EnabledToolsets: cfg.EnabledToolsets,
67+
DynamicToolsets: cfg.DynamicToolsets,
68+
ReadOnly: cfg.ReadOnly,
69+
Translator: translator,
70+
ContentWindowSize: cfg.ContentWindowSize,
71+
TokenProvider: TokenFromContext,
72+
})
73+
if err != nil {
74+
return fmt.Errorf("failed to create MCP server: %w", err)
75+
}
76+
77+
var logOutput io.Writer
78+
var logFile *os.File
79+
var slogHandler slog.Handler
80+
81+
if strings.TrimSpace(cfg.LogFilePath) != "" {
82+
file, fileErr := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
83+
if fileErr != nil {
84+
return fmt.Errorf("failed to open log file: %w", fileErr)
85+
}
86+
logOutput = file
87+
logFile = file
88+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
89+
} else {
90+
logOutput = os.Stderr
91+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
92+
}
93+
94+
logger := slog.New(slogHandler)
95+
if logFile != nil {
96+
defer func() { _ = logFile.Close() }()
97+
}
98+
httpServer := &http.Server{Addr: listenAddress}
99+
100+
streamServer := server.NewStreamableHTTPServer(
101+
ghServer,
102+
server.WithStreamableHTTPServer(httpServer),
103+
server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
104+
return pkgErrors.ContextWithGitHubErrors(ctx)
105+
}),
106+
)
107+
108+
mux := http.NewServeMux()
109+
mux.HandleFunc(healthPath, healthHandler)
110+
111+
protectedHandler := tokenMiddleware(streamServer)
112+
mux.Handle(endpointPath, protectedHandler)
113+
if !strings.HasSuffix(endpointPath, "/") {
114+
mux.Handle(endpointPath+"/", protectedHandler)
115+
}
116+
117+
httpServer.Handler = mux
118+
119+
errCh := make(chan error, 1)
120+
go func() {
121+
logger.Info("starting HTTP server", "address", listenAddress, "endpoint", endpointPath, "health", healthPath, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly)
122+
if serveErr := httpServer.ListenAndServe(); serveErr != nil && !stdErrors.Is(serveErr, http.ErrServerClosed) {
123+
errCh <- serveErr
124+
return
125+
}
126+
errCh <- nil
127+
}()
128+
129+
select {
130+
case <-ctx.Done():
131+
logger.Info("shutting down HTTP server", "reason", ctx.Err())
132+
case serveErr := <-errCh:
133+
if serveErr != nil {
134+
logger.Error("error running HTTP server", "error", serveErr)
135+
return fmt.Errorf("error running HTTP server: %w", serveErr)
136+
}
137+
logger.Info("HTTP server stopped")
138+
return nil
139+
}
140+
141+
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
142+
defer cancel()
143+
144+
if shutdownErr := streamServer.Shutdown(shutdownCtx); shutdownErr != nil && !stdErrors.Is(shutdownErr, http.ErrServerClosed) && !stdErrors.Is(shutdownErr, context.Canceled) {
145+
logger.Error("error during server shutdown", "error", shutdownErr)
146+
return fmt.Errorf("failed to shutdown HTTP server: %w", shutdownErr)
147+
}
148+
149+
if serveErr := <-errCh; serveErr != nil && !stdErrors.Is(serveErr, http.ErrServerClosed) {
150+
logger.Error("error after server shutdown", "error", serveErr)
151+
return fmt.Errorf("error shutting down HTTP server: %w", serveErr)
152+
}
153+
154+
logger.Info("HTTP server shutdown complete")
155+
return nil
156+
}
157+
158+
func normalizePath(path string, fallback string) string {
159+
trimmed := strings.TrimSpace(path)
160+
if trimmed == "" {
161+
return fallback
162+
}
163+
if !strings.HasPrefix(trimmed, "/") {
164+
trimmed = "/" + trimmed
165+
}
166+
if len(trimmed) > 1 && strings.HasSuffix(trimmed, "/") {
167+
trimmed = strings.TrimSuffix(trimmed, "/")
168+
}
169+
return trimmed
170+
}
171+
172+
func healthHandler(w http.ResponseWriter, r *http.Request) {
173+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
174+
w.WriteHeader(http.StatusMethodNotAllowed)
175+
return
176+
}
177+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
178+
w.WriteHeader(http.StatusOK)
179+
if r.Method == http.MethodHead {
180+
return
181+
}
182+
_, _ = w.Write([]byte("ok\n"))
183+
}
184+
185+
func tokenMiddleware(next http.Handler) http.Handler {
186+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187+
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
188+
if authHeader == "" {
189+
unauthorized(w, "missing Authorization header")
190+
return
191+
}
192+
193+
parts := strings.SplitN(authHeader, " ", 2)
194+
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
195+
unauthorized(w, "invalid Authorization header")
196+
return
197+
}
198+
199+
token := strings.TrimSpace(parts[1])
200+
if token == "" {
201+
unauthorized(w, "missing bearer token")
202+
return
203+
}
204+
205+
ctx := ContextWithToken(r.Context(), token)
206+
next.ServeHTTP(w, r.WithContext(ctx))
207+
})
208+
}
209+
210+
func unauthorized(w http.ResponseWriter, message string) {
211+
w.Header().Set("WWW-Authenticate", "Bearer realm=\"github-mcp-server\"")
212+
http.Error(w, message, http.StatusUnauthorized)
213+
}

0 commit comments

Comments
 (0)