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 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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,3 +675,48 @@ 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 GitHub clients per request using token-aware client factories.
- Optimized for performance: single MCP server instance with per-request authentication.
- 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)")
}
}
166 changes: 166 additions & 0 deletions internal/ghmcp/multiuser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package ghmcp

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"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))
}
}

func TestExtractTokenFromRequest_StrictValidation(t *testing.T) {
tests := []struct {
name string
header string
expected string
}{
{"valid bearer token", "Bearer ghp_1234567890abcdef", "ghp_1234567890abcdef"},
{"valid long token", "Bearer github_pat_11AAAAAAA0AAAAAAAAAAAAAAAAA", "github_pat_11AAAAAAA0AAAAAAAAAAAAAAAAA"},
{"missing header", "", ""},
{"malformed - no Bearer", "ghp_1234567890abcdef", ""},
{"malformed - wrong case", "bearer ghp_1234567890abcdef", ""},
{"too short token", "Bearer abc", ""},
{"empty token", "Bearer ", ""},
{"only Bearer", "Bearer", ""},
{"spaces in token", "Bearer ghp_123 456", "ghp_123 456"}, // This should work
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &http.Request{
Header: http.Header{},
}
if tt.header != "" {
req.Header.Set("Authorization", tt.header)
}

result := extractTokenFromRequest(req)
if result != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, result)
}
})
}
}

func TestMultiUserHandler_ContextInjection(t *testing.T) {
// Mock MCP server that verifies token is in context
mockMCP := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := r.Context().Value("github_token").(string)
if !ok {
t.Error("token not found in context")
w.WriteHeader(http.StatusInternalServerError)
return
}
if token != "test_token_123456" {
t.Errorf("wrong token in context: %s", token)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success":true}`))
})

handler := &multiUserHandler{mcpServer: mockMCP}

req := httptest.NewRequest("POST", "/", strings.NewReader("{}"))
req.Header.Set("Authorization", "Bearer test_token_123456")

w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
}

func TestMultiUserHandler_MissingToken(t *testing.T) {
// Mock MCP server (should not be called)
mockMCP := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("MCP server should not be called when token is missing")
})

handler := &multiUserHandler{mcpServer: mockMCP}

req := httptest.NewRequest("POST", "/", strings.NewReader("{}"))
// No Authorization header

w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}

contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("expected Content-Type: application/json, got %s", contentType)
}

if !strings.Contains(w.Body.String(), "missing GitHub token") {
t.Errorf("expected error message about missing token, got: %s", w.Body.String())
}
}
Loading