Skip to content

Commit 13b4635

Browse files
Talal Riazclaude
andcommitted
Add HTTP server mode with OAuth token support
This adds HTTP server mode to the GitHub MCP Server, enabling multi-client support with "bring your own token" OAuth functionality. This is useful for enterprise scenarios where a single MCP server instance handles multiple external clients, each authenticating with their own credentials. Key changes: - Add `http` command to start HTTP server on configurable port (default 8080) - Support per-request OAuth tokens via Authorization header - Fall back to GITHUB_PERSONAL_ACCESS_TOKEN env var if no header provided - Modify client factories to extract token from request context - Add comprehensive HTTP server documentation to README This implementation is inspired by PR github#888 by @Dreadnoth, updated to work with the current codebase architecture and dependencies. Co-authored-by: Dreadnoth <15017947+Dreadnoth@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0188cc0 commit 13b4635

6 files changed

Lines changed: 234 additions & 2 deletions

File tree

‎Dockerfile‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server"
2626
WORKDIR /server
2727
# Copy the binary from the build stage
2828
COPY --from=build /bin/github-mcp-server .
29+
30+
EXPOSE 8080
31+
2932
# Set the entrypoint to the server binary
3033
ENTRYPOINT ["/server/github-mcp-server"]
3134
# Default arguments for ENTRYPOINT

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,43 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to
121121

122122
---
123123

124+
## HTTP Server Mode
125+
126+
The GitHub MCP Server can run in HTTP mode, allowing it to serve multiple clients concurrently. This is useful for enterprise scenarios where you want to run a single MCP server instance that handles multiple external clients.
127+
128+
### Starting the HTTP Server
129+
130+
To run the server in HTTP mode, use the `http` command:
131+
132+
```bash
133+
github-mcp-server http --port 8080
134+
```
135+
136+
Or with Docker:
137+
138+
```bash
139+
docker run -p 8080:8080 \
140+
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-token> \
141+
ghcr.io/github/github-mcp-server http --port 8080
142+
```
143+
144+
### HTTP Server with "Bring Your Own Token"
145+
146+
When running the server in HTTP mode, clients can provide their own GitHub token with each request using the `Authorization` header:
147+
148+
```http
149+
Authorization: Bearer <github-token>
150+
```
151+
152+
This allows each client to authenticate with their own credentials, enabling:
153+
- Multi-tenant deployments where each user has their own access level
154+
- Enterprise use cases with centralized MCP server infrastructure
155+
- OAuth-based authentication flows
156+
157+
If no `Authorization` header is provided, the server will fall back to using the token specified via the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable (if configured).
158+
159+
---
160+
124161
## Local GitHub MCP Server
125162

126163
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders)

‎cmd/github-mcp-server/main.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,39 @@ var (
2626
Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date),
2727
}
2828

29+
httpCmd = &cobra.Command{
30+
Use: "http",
31+
Short: "Start HTTP server",
32+
Long: `Start a server that communicates via HTTP using the MCP protocol.`,
33+
RunE: func(_ *cobra.Command, _ []string) error {
34+
token := viper.GetString("personal_access_token")
35+
36+
var enabledToolsets []string
37+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
38+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
39+
}
40+
41+
if len(enabledToolsets) == 0 {
42+
enabledToolsets = github.GetDefaultToolsetIDs()
43+
}
44+
45+
httpServerConfig := ghmcp.HTTPServerConfig{
46+
Version: version,
47+
Host: viper.GetString("host"),
48+
Token: token,
49+
EnabledToolsets: enabledToolsets,
50+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
51+
ReadOnly: viper.GetBool("read-only"),
52+
ExportTranslations: viper.GetBool("export-translations"),
53+
EnableCommandLogging: viper.GetBool("enable-command-logging"),
54+
LogFilePath: viper.GetString("log-file"),
55+
ContentWindowSize: viper.GetInt("content-window-size"),
56+
Port: viper.GetInt("port"),
57+
}
58+
return ghmcp.RunHTTPServer(httpServerConfig)
59+
},
60+
}
61+
2962
stdioCmd = &cobra.Command{
3063
Use: "stdio",
3164
Short: "Start stdio server",
@@ -94,6 +127,10 @@ func init() {
94127

95128
// Add subcommands
96129
rootCmd.AddCommand(stdioCmd)
130+
rootCmd.AddCommand(httpCmd)
131+
132+
httpCmd.Flags().Int("port", 8080, "Port to listen on for HTTP server")
133+
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
97134
}
98135

99136
func initConfig() {

‎go.mod‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/invopop/jsonschema v0.13.0 // indirect
2121
github.com/josharian/intern v1.0.0 // indirect
2222
github.com/mailru/easyjson v0.7.7 // indirect
23+
github.com/sirupsen/logrus v1.9.3 // indirect
2324
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
2425
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
2526
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect

‎go.sum‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkv
7272
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
7373
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
7474
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
75+
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
76+
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
7577
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
7678
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
7779
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
@@ -87,6 +89,7 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
8789
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
8890
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
8991
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
92+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
9093
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
9194
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
9295
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -103,6 +106,7 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
103106
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
104107
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
105108
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
109+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106110
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
107111
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
108112
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=

‎internal/ghmcp/server.go‎

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os/signal"
1313
"strings"
1414
"syscall"
15+
"time"
1516

1617
"github.com/github/github-mcp-server/pkg/errors"
1718
"github.com/github/github-mcp-server/pkg/github"
@@ -22,6 +23,7 @@ import (
2223
"github.com/mark3labs/mcp-go/mcp"
2324
"github.com/mark3labs/mcp-go/server"
2425
"github.com/shurcooL/githubv4"
26+
"github.com/sirupsen/logrus"
2527
)
2628

2729
type MCPServerConfig struct {
@@ -124,11 +126,39 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
124126
server.WithHooks(hooks),
125127
)
126128

127-
getClient := func(_ context.Context) (*gogithub.Client, error) {
129+
getClient := func(ctx context.Context) (*gogithub.Client, error) {
130+
if tokenVal := ctx.Value(githubTokenKey{}); tokenVal != nil {
131+
if token, ok := tokenVal.(string); ok && token != "" {
132+
client := gogithub.NewClient(nil).WithAuthToken(token)
133+
client.UserAgent = restClient.UserAgent
134+
client.BaseURL = apiHost.baseRESTURL
135+
client.UploadURL = apiHost.uploadURL
136+
return client, nil
137+
}
138+
}
128139
return restClient, nil // closing over client
129140
}
130141

131-
getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
142+
getGQLClient := func(ctx context.Context) (*githubv4.Client, error) {
143+
if tokenVal := ctx.Value(githubTokenKey{}); tokenVal != nil {
144+
if token, ok := tokenVal.(string); ok && token != "" {
145+
httpClient := &http.Client{
146+
Transport: &bearerAuthTransport{
147+
transport: http.DefaultTransport,
148+
token: token,
149+
},
150+
}
151+
if gqlHTTPClient.Transport != nil {
152+
if uaTransport, ok := gqlHTTPClient.Transport.(*userAgentTransport); ok {
153+
httpClient.Transport = &userAgentTransport{
154+
transport: httpClient.Transport,
155+
agent: uaTransport.agent,
156+
}
157+
}
158+
}
159+
return githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), httpClient), nil
160+
}
161+
}
132162
return gqlClient, nil // closing over client
133163
}
134164

@@ -159,6 +189,46 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
159189
return ghServer, nil
160190
}
161191

192+
type githubTokenKey struct{}
193+
194+
type HTTPServerConfig struct {
195+
// Version of the server
196+
Version string
197+
198+
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
199+
Host string
200+
201+
// GitHub Token to authenticate with the GitHub API (optional for HTTP mode with OAuth)
202+
Token string
203+
204+
// EnabledToolsets is a list of toolsets to enable
205+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
206+
EnabledToolsets []string
207+
208+
// Whether to enable dynamic toolsets
209+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
210+
DynamicToolsets bool
211+
212+
// ReadOnly indicates if we should only register read-only tools
213+
ReadOnly bool
214+
215+
// ExportTranslations indicates if we should export translations
216+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
217+
ExportTranslations bool
218+
219+
// EnableCommandLogging indicates if we should log commands
220+
EnableCommandLogging bool
221+
222+
// Path to the log file if not stderr
223+
LogFilePath string
224+
225+
// Content window size
226+
ContentWindowSize int
227+
228+
// Port to listen on for HTTP server
229+
Port int
230+
}
231+
162232
type StdioServerConfig struct {
163233
// Version of the server
164234
Version string
@@ -194,6 +264,77 @@ type StdioServerConfig struct {
194264
ContentWindowSize int
195265
}
196266

267+
func RunHTTPServer(cfg HTTPServerConfig) error {
268+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
269+
defer stop()
270+
271+
t, dumpTranslations := translations.TranslationHelper()
272+
273+
ghServer, err := NewMCPServer(MCPServerConfig{
274+
Version: cfg.Version,
275+
Host: cfg.Host,
276+
Token: cfg.Token,
277+
EnabledToolsets: cfg.EnabledToolsets,
278+
DynamicToolsets: cfg.DynamicToolsets,
279+
ReadOnly: cfg.ReadOnly,
280+
Translator: t,
281+
ContentWindowSize: cfg.ContentWindowSize,
282+
})
283+
if err != nil {
284+
return fmt.Errorf("failed to create MCP server: %w", err)
285+
}
286+
287+
logrusLogger := logrus.New()
288+
if cfg.LogFilePath != "" {
289+
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
290+
if err != nil {
291+
return fmt.Errorf("failed to open log file: %w", err)
292+
}
293+
294+
logrusLogger.SetLevel(logrus.DebugLevel)
295+
logrusLogger.SetOutput(file)
296+
}
297+
298+
httpOptions := []server.StreamableHTTPOption{
299+
server.WithLogger(logrusLogger),
300+
server.WithHeartbeatInterval(30 * time.Second),
301+
server.WithHTTPContextFunc(extractTokenFromAuthHeader),
302+
}
303+
304+
httpServer := server.NewStreamableHTTPServer(ghServer, httpOptions...)
305+
306+
if cfg.ExportTranslations {
307+
dumpTranslations()
308+
}
309+
310+
addr := fmt.Sprintf(":%d", cfg.Port)
311+
srv := &http.Server{
312+
Addr: addr,
313+
Handler: httpServer,
314+
}
315+
316+
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on HTTP at %s\n", addr)
317+
318+
errC := make(chan error, 1)
319+
go func() {
320+
errC <- srv.ListenAndServe()
321+
}()
322+
323+
select {
324+
case <-ctx.Done():
325+
logrusLogger.Infof("shutting down server...")
326+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
327+
defer cancel()
328+
return srv.Shutdown(shutdownCtx)
329+
case err := <-errC:
330+
if err != nil && err != http.ErrServerClosed {
331+
return fmt.Errorf("error running server: %w", err)
332+
}
333+
}
334+
335+
return nil
336+
}
337+
197338
// RunStdioServer is not concurrent safe.
198339
func RunStdioServer(cfg StdioServerConfig) error {
199340
// Create app context
@@ -427,3 +568,12 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro
427568
req.Header.Set("Authorization", "Bearer "+t.token)
428569
return t.transport.RoundTrip(req)
429570
}
571+
572+
func extractTokenFromAuthHeader(ctx context.Context, r *http.Request) context.Context {
573+
authHeader := r.Header.Get("Authorization")
574+
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
575+
token := strings.TrimPrefix(authHeader, "Bearer ")
576+
return context.WithValue(ctx, githubTokenKey{}, token)
577+
}
578+
return ctx
579+
}

0 commit comments

Comments
 (0)