Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -121,6 +121,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 @@ -94,6 +127,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
154 changes: 152 additions & 2 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os/signal"
"strings"
"syscall"
"time"

"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
Expand All @@ -22,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 @@ -124,11 +126,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 @@ -159,6 +189,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 @@ -194,6 +264,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 @@ -427,3 +568,12 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro
req.Header.Set("Authorization", "Bearer "+t.token)
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
}