Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server"
WORKDIR /server
# Copy the binary from the build stage
COPY --from=build /bin/github-mcp-server .

EXPOSE 8080

# Set the entrypoint to the server binary
ENTRYPOINT ["/server/github-mcp-server"]
# Default arguments for ENTRYPOINT
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,43 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to

---

## HTTP Server Mode

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.

### Starting the HTTP Server

To run the server in HTTP mode, use the `http` command:

```bash
github-mcp-server http --port 8080
```

Or with Docker:

```bash
docker run -p 8080:8080 \
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-token> \
ghcr.io/github/github-mcp-server http --port 8080
```

### HTTP Server with "Bring Your Own Token"

When running the server in HTTP mode, clients can provide their own GitHub token with each request using the `Authorization` header:

```http
Authorization: Bearer <github-token>
```

This allows each client to authenticate with their own credentials, enabling:
- Multi-tenant deployments where each user has their own access level
- Enterprise use cases with centralized MCP server infrastructure
- OAuth-based authentication flows

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

---

## Local GitHub MCP Server

[![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)
Expand Down
37 changes: 37 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,39 @@ var (
Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date),
}

httpCmd = &cobra.Command{
Use: "http",
Short: "Start HTTP server",
Long: `Start a server that communicates via HTTP using the MCP protocol.`,
RunE: func(_ *cobra.Command, _ []string) error {
token := viper.GetString("personal_access_token")

var enabledToolsets []string
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}

if len(enabledToolsets) == 0 {
enabledToolsets = github.GetDefaultToolsetIDs()
}

httpServerConfig := ghmcp.HTTPServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
Port: viper.GetInt("port"),
}
return ghmcp.RunHTTPServer(httpServerConfig)
},
}

stdioCmd = &cobra.Command{
Use: "stdio",
Short: "Start stdio server",
Expand Down Expand Up @@ -95,6 +128,10 @@ func init() {

// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)

httpCmd.Flags().Int("port", 8080, "Port to listen on for HTTP server")
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
}

func initConfig() {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkv
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
Expand All @@ -87,6 +89,7 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
Expand All @@ -103,6 +106,7 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
Expand Down
151 changes: 149 additions & 2 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
"github.com/sirupsen/logrus"
)

type MCPServerConfig struct {
Expand Down Expand Up @@ -120,11 +121,39 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
server.WithHooks(hooks),
)

getClient := func(_ context.Context) (*gogithub.Client, error) {
getClient := func(ctx context.Context) (*gogithub.Client, error) {
if tokenVal := ctx.Value(githubTokenKey{}); tokenVal != nil {
if token, ok := tokenVal.(string); ok && token != "" {
client := gogithub.NewClient(nil).WithAuthToken(token)
client.UserAgent = restClient.UserAgent
client.BaseURL = apiHost.baseRESTURL
client.UploadURL = apiHost.uploadURL
return client, nil
}
}
return restClient, nil // closing over client
}

getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
getGQLClient := func(ctx context.Context) (*githubv4.Client, error) {
if tokenVal := ctx.Value(githubTokenKey{}); tokenVal != nil {
if token, ok := tokenVal.(string); ok && token != "" {
httpClient := &http.Client{
Transport: &bearerAuthTransport{
transport: http.DefaultTransport,
token: token,
},
}
if gqlHTTPClient.Transport != nil {
if uaTransport, ok := gqlHTTPClient.Transport.(*userAgentTransport); ok {
httpClient.Transport = &userAgentTransport{
transport: httpClient.Transport,
agent: uaTransport.agent,
}
}
}
return githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), httpClient), nil
}
}
return gqlClient, nil // closing over client
}

Expand Down Expand Up @@ -155,6 +184,46 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
return ghServer, nil
}

type githubTokenKey struct{}

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

// GitHub Token to authenticate with the GitHub API (optional for HTTP mode with OAuth)
Token string

// EnabledToolsets is a list of toolsets to enable
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []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

// ExportTranslations indicates if we should export translations
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
ExportTranslations bool

// EnableCommandLogging indicates if we should log commands
EnableCommandLogging bool

// Path to the log file if not stderr
LogFilePath string

// Content window size
ContentWindowSize int

// Port to listen on for HTTP server
Port int
}

type StdioServerConfig struct {
// Version of the server
Version string
Expand Down Expand Up @@ -190,6 +259,77 @@ type StdioServerConfig struct {
ContentWindowSize int
}

func RunHTTPServer(cfg HTTPServerConfig) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

t, dumpTranslations := translations.TranslationHelper()

ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}

logrusLogger := logrus.New()
if cfg.LogFilePath != "" {
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
Copy link

Copilot AI Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The file permissions 0600 are appropriate for log files containing potentially sensitive information, but consider using 0640 if the log file needs to be readable by a logging service or monitoring system running under a different user in the same group.

Suggested change
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0640)

Copilot uses AI. Check for mistakes.

if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}

logrusLogger.SetLevel(logrus.DebugLevel)
logrusLogger.SetOutput(file)
}

httpOptions := []server.StreamableHTTPOption{
server.WithLogger(logrusLogger),
server.WithHeartbeatInterval(30 * time.Second),
server.WithHTTPContextFunc(extractTokenFromAuthHeader),
}

httpServer := server.NewStreamableHTTPServer(ghServer, httpOptions...)

if cfg.ExportTranslations {
dumpTranslations()
}

addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: httpServer,
}

_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on HTTP at %s\n", addr)

errC := make(chan error, 1)
go func() {
errC <- srv.ListenAndServe()
}()

select {
case <-ctx.Done():
logrusLogger.Infof("Shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errC:
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("error running server: %w", err)
}
}

return nil
}

// RunStdioServer is not concurrent safe.
func RunStdioServer(cfg StdioServerConfig) error {
// Create app context
Expand Down Expand Up @@ -466,6 +606,13 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro
return t.transport.RoundTrip(req)
}

func extractTokenFromAuthHeader(ctx context.Context, r *http.Request) context.Context {
authHeader := r.Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
return context.WithValue(ctx, githubTokenKey{}, token)
}
return ctx
// cleanToolsets cleans and handles special toolset keywords:
// - Duplicates are removed from the result
// - Removes whitespaces
Expand Down