Skip to content

Add multi-user HTTP mode: per-request GitHub token, docs, and tests #489

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Add multi-user HTTP mode: per-request GitHub token, docs, and tests
  • Loading branch information
thomcost committed Jun 7, 2025
commit 77bb8237fddd2da215a1f751b3ad8ba1cf0c6c20
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,3 +675,47 @@ The exported Go API of this module should currently be considered unstable, and
## License

This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms.

## Multi-User HTTP Mode (Experimental)

The GitHub MCP Server supports a multi-user HTTP mode for enterprise and cloud scenarios. In this mode, the server does **not** require a global GitHub token at startup. Instead, each HTTP request must include a GitHub token in the `Authorization` header:

- The token is **never** passed as a tool parameter or exposed to the agent/model.
- The server extracts the token from the `Authorization` header for each request and creates a new GitHub client per request.
- This enables secure, scalable, and multi-tenant deployments.

### Usage

Start the server in multi-user mode on a configurable port (default: 8080):

```bash
./github-mcp-server multi-user --port 8080
```

#### Example HTTP Request

```http
POST /v1/mcp HTTP/1.1
Host: localhost:8080
Authorization: Bearer <your-github-token>
Content-Type: application/json

{ ...MCP request body... }
```

- The `Authorization` header is **required** for every request.
- The server will return 401 Unauthorized if the header is missing.

### Security Note
- The agent and model never see the token value.
- This is the recommended and secure approach for HTTP APIs.

### Use Cases
- Multi-tenant SaaS
- Shared enterprise deployments
- Web integrations where each user authenticates with their own GitHub token

### Backward Compatibility
- Single-user `stdio` and HTTP modes are still supported and unchanged.

---
30 changes: 30 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ var (
return ghmcp.RunStdioServer(stdioServerConfig)
},
}

multiUserCmd = &cobra.Command{
Use: "multi-user",
Short: "Start multi-user HTTP server (experimental)",
Long: `Start a streamable HTTP server that supports per-request GitHub authentication tokens for multi-user scenarios.`,
RunE: func(cmd *cobra.Command, _ []string) error {
port := viper.GetInt("port")
if port == 0 {
port = 8080 // default
}

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

multiUserConfig := ghmcp.MultiUserHTTPServerConfig{
Version: version,
Host: viper.GetString("host"),
EnabledToolsets: enabledToolsets,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
Port: port,
}
return ghmcp.RunMultiUserHTTPServer(multiUserConfig)
},
}
)

func init() {
Expand All @@ -73,6 +100,7 @@ func init() {
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
rootCmd.PersistentFlags().Int("port", 8080, "Port to bind the HTTP server to (multi-user mode)")

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
Expand All @@ -82,9 +110,11 @@ func init() {
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
_ = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))

// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(multiUserCmd)
}

func initConfig() {
Expand Down
47 changes: 47 additions & 0 deletions e2e/multiuser_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//go:build e2e

package e2e_test

import (
"bytes"
"encoding/json"
"net/http"
"os/exec"
"testing"
"time"
)

func TestMultiUserHTTPServer_Integration(t *testing.T) {
// Start the server in multi-user mode on a random port (e.g. 18080)
cmd := exec.Command("../cmd/github-mcp-server/github-mcp-server", "multi-user", "--port", "18080")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start server: %v", err)
}
defer cmd.Process.Kill()
// Wait for server to start
time.Sleep(2 * time.Second)

// Make a request without Authorization header
resp, err := http.Post("http://localhost:18080/v1/mcp", "application/json", bytes.NewBufferString(`{"test":"noauth"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized, got %d", resp.StatusCode)
}

// Make a request with Authorization header
body, _ := json.Marshal(map[string]string{"test": "authed"})
req, _ := http.NewRequest("POST", "http://localhost:18080/v1/mcp", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer testtoken123")
req.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp2.StatusCode == http.StatusUnauthorized {
t.Errorf("expected not 401, got 401 (token should be accepted if server is running and token is valid)")
}
}
72 changes: 72 additions & 0 deletions internal/ghmcp/multiuser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package ghmcp

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)

type dummyRequest struct {
Test string `json:"test"`
}

func TestMultiUserHTTPServer_TokenRequired(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractTokenFromRequest(r)
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"missing GitHub token in Authorization header"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})

ts := httptest.NewServer(h)
defer ts.Close()

// No Authorization header
resp, err := http.Post(ts.URL, "application/json", bytes.NewBufferString(`{"test":"noauth"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized, got %d", resp.StatusCode)
}
}

func TestMultiUserHTTPServer_ValidToken(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractTokenFromRequest(r)
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"missing GitHub token in Authorization header"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"token":"` + token + `"}`))
})

ts := httptest.NewServer(h)
defer ts.Close()

// With Authorization header
body, _ := json.Marshal(dummyRequest{Test: "authed"})
req, _ := http.NewRequest("POST", ts.URL, bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer testtoken123")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 OK, got %d", resp.StatusCode)
}
data, _ := io.ReadAll(resp.Body)
if !bytes.Contains(data, []byte("testtoken123")) {
t.Errorf("expected token in response, got %s", string(data))
}
}
68 changes: 67 additions & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {

enabledToolsets := cfg.EnabledToolsets
if cfg.DynamicToolsets {
// filter "all" from the enabled toolsets
// filter "all" from the enabled tool sets
enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets))
for _, toolset := range cfg.EnabledToolsets {
if toolset != "all" {
Expand Down Expand Up @@ -374,3 +374,69 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro
req.Header.Set("Authorization", "Bearer "+t.token)
return t.transport.RoundTrip(req)
}

// MultiUserHTTPServerConfig holds config for the multi-user HTTP server
// (no global token, per-request tokens)
type MultiUserHTTPServerConfig struct {
Version string
Host string
EnabledToolsets []string
DynamicToolsets bool
ReadOnly bool
Port int
}

// RunMultiUserHTTPServer starts a streamable HTTP server that supports per-request GitHub tokens
func RunMultiUserHTTPServer(cfg MultiUserHTTPServerConfig) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

t, _ := translations.TranslationHelper()

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractTokenFromRequest(r)
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"missing GitHub token in Authorization header"}`))
return
}
ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"failed to create MCP server"}`))
return
}
// Use the MCP server's HTTP handler for this request
mcpHTTP := server.NewStreamableHTTPServer(ghServer)
mcpHTTP.ServeHTTP(w, r)
})

addr := fmt.Sprintf(":%d", cfg.Port)
fmt.Fprintf(os.Stderr, "GitHub MCP Server running in multi-user HTTP mode on %s\n", addr)
server := &http.Server{Addr: addr, Handler: handler}
go func() {
<-ctx.Done()
_ = server.Shutdown(context.Background())
}()
return server.ListenAndServe()
}

// extractTokenFromRequest extracts the GitHub token from the Authorization header
func extractTokenFromRequest(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return h
}