Skip to content

Commit 77bb823

Browse files
committed
Add multi-user HTTP mode: per-request GitHub token, docs, and tests
1 parent c17ebfe commit 77bb823

File tree

5 files changed

+260
-1
lines changed

5 files changed

+260
-1
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,3 +675,47 @@ The exported Go API of this module should currently be considered unstable, and
675675
## License
676676

677677
This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms.
678+
679+
## Multi-User HTTP Mode (Experimental)
680+
681+
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:
682+
683+
- The token is **never** passed as a tool parameter or exposed to the agent/model.
684+
- The server extracts the token from the `Authorization` header for each request and creates a new GitHub client per request.
685+
- This enables secure, scalable, and multi-tenant deployments.
686+
687+
### Usage
688+
689+
Start the server in multi-user mode on a configurable port (default: 8080):
690+
691+
```bash
692+
./github-mcp-server multi-user --port 8080
693+
```
694+
695+
#### Example HTTP Request
696+
697+
```http
698+
POST /v1/mcp HTTP/1.1
699+
Host: localhost:8080
700+
Authorization: Bearer <your-github-token>
701+
Content-Type: application/json
702+
703+
{ ...MCP request body... }
704+
```
705+
706+
- The `Authorization` header is **required** for every request.
707+
- The server will return 401 Unauthorized if the header is missing.
708+
709+
### Security Note
710+
- The agent and model never see the token value.
711+
- This is the recommended and secure approach for HTTP APIs.
712+
713+
### Use Cases
714+
- Multi-tenant SaaS
715+
- Shared enterprise deployments
716+
- Web integrations where each user authenticates with their own GitHub token
717+
718+
### Backward Compatibility
719+
- Single-user `stdio` and HTTP modes are still supported and unchanged.
720+
721+
---

cmd/github-mcp-server/main.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ var (
5858
return ghmcp.RunStdioServer(stdioServerConfig)
5959
},
6060
}
61+
62+
multiUserCmd = &cobra.Command{
63+
Use: "multi-user",
64+
Short: "Start multi-user HTTP server (experimental)",
65+
Long: `Start a streamable HTTP server that supports per-request GitHub authentication tokens for multi-user scenarios.`,
66+
RunE: func(cmd *cobra.Command, _ []string) error {
67+
port := viper.GetInt("port")
68+
if port == 0 {
69+
port = 8080 // default
70+
}
71+
72+
var enabledToolsets []string
73+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
74+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
75+
}
76+
77+
multiUserConfig := ghmcp.MultiUserHTTPServerConfig{
78+
Version: version,
79+
Host: viper.GetString("host"),
80+
EnabledToolsets: enabledToolsets,
81+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
82+
ReadOnly: viper.GetBool("read-only"),
83+
Port: port,
84+
}
85+
return ghmcp.RunMultiUserHTTPServer(multiUserConfig)
86+
},
87+
}
6188
)
6289

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

77105
// Bind flag to viper
78106
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -82,9 +110,11 @@ func init() {
82110
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
83111
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
84112
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
113+
_ = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
85114

86115
// Add subcommands
87116
rootCmd.AddCommand(stdioCmd)
117+
rootCmd.AddCommand(multiUserCmd)
88118
}
89119

90120
func initConfig() {

e2e/multiuser_e2e_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//go:build e2e
2+
3+
package e2e_test
4+
5+
import (
6+
"bytes"
7+
"encoding/json"
8+
"net/http"
9+
"os/exec"
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestMultiUserHTTPServer_Integration(t *testing.T) {
15+
// Start the server in multi-user mode on a random port (e.g. 18080)
16+
cmd := exec.Command("../cmd/github-mcp-server/github-mcp-server", "multi-user", "--port", "18080")
17+
cmd.Stdout = nil
18+
cmd.Stderr = nil
19+
if err := cmd.Start(); err != nil {
20+
t.Fatalf("failed to start server: %v", err)
21+
}
22+
defer cmd.Process.Kill()
23+
// Wait for server to start
24+
time.Sleep(2 * time.Second)
25+
26+
// Make a request without Authorization header
27+
resp, err := http.Post("http://localhost:18080/v1/mcp", "application/json", bytes.NewBufferString(`{"test":"noauth"}`))
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
if resp.StatusCode != http.StatusUnauthorized {
32+
t.Errorf("expected 401 Unauthorized, got %d", resp.StatusCode)
33+
}
34+
35+
// Make a request with Authorization header
36+
body, _ := json.Marshal(map[string]string{"test": "authed"})
37+
req, _ := http.NewRequest("POST", "http://localhost:18080/v1/mcp", bytes.NewBuffer(body))
38+
req.Header.Set("Authorization", "Bearer testtoken123")
39+
req.Header.Set("Content-Type", "application/json")
40+
resp2, err := http.DefaultClient.Do(req)
41+
if err != nil {
42+
t.Fatalf("unexpected error: %v", err)
43+
}
44+
if resp2.StatusCode == http.StatusUnauthorized {
45+
t.Errorf("expected not 401, got 401 (token should be accepted if server is running and token is valid)")
46+
}
47+
}

internal/ghmcp/multiuser_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package ghmcp
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
)
11+
12+
type dummyRequest struct {
13+
Test string `json:"test"`
14+
}
15+
16+
func TestMultiUserHTTPServer_TokenRequired(t *testing.T) {
17+
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
token := extractTokenFromRequest(r)
19+
if token == "" {
20+
w.WriteHeader(http.StatusUnauthorized)
21+
_, _ = w.Write([]byte(`{"error":"missing GitHub token in Authorization header"}`))
22+
return
23+
}
24+
w.WriteHeader(http.StatusOK)
25+
_, _ = w.Write([]byte(`{"ok":true}`))
26+
})
27+
28+
ts := httptest.NewServer(h)
29+
defer ts.Close()
30+
31+
// No Authorization header
32+
resp, err := http.Post(ts.URL, "application/json", bytes.NewBufferString(`{"test":"noauth"}`))
33+
if err != nil {
34+
t.Fatalf("unexpected error: %v", err)
35+
}
36+
if resp.StatusCode != http.StatusUnauthorized {
37+
t.Errorf("expected 401 Unauthorized, got %d", resp.StatusCode)
38+
}
39+
}
40+
41+
func TestMultiUserHTTPServer_ValidToken(t *testing.T) {
42+
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
token := extractTokenFromRequest(r)
44+
if token == "" {
45+
w.WriteHeader(http.StatusUnauthorized)
46+
_, _ = w.Write([]byte(`{"error":"missing GitHub token in Authorization header"}`))
47+
return
48+
}
49+
w.WriteHeader(http.StatusOK)
50+
_, _ = w.Write([]byte(`{"ok":true,"token":"` + token + `"}`))
51+
})
52+
53+
ts := httptest.NewServer(h)
54+
defer ts.Close()
55+
56+
// With Authorization header
57+
body, _ := json.Marshal(dummyRequest{Test: "authed"})
58+
req, _ := http.NewRequest("POST", ts.URL, bytes.NewBuffer(body))
59+
req.Header.Set("Authorization", "Bearer testtoken123")
60+
req.Header.Set("Content-Type", "application/json")
61+
resp, err := http.DefaultClient.Do(req)
62+
if err != nil {
63+
t.Fatalf("unexpected error: %v", err)
64+
}
65+
if resp.StatusCode != http.StatusOK {
66+
t.Errorf("expected 200 OK, got %d", resp.StatusCode)
67+
}
68+
data, _ := io.ReadAll(resp.Body)
69+
if !bytes.Contains(data, []byte("testtoken123")) {
70+
t.Errorf("expected token in response, got %s", string(data))
71+
}
72+
}

internal/ghmcp/server.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
9595

9696
enabledToolsets := cfg.EnabledToolsets
9797
if cfg.DynamicToolsets {
98-
// filter "all" from the enabled toolsets
98+
// filter "all" from the enabled tool sets
9999
enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets))
100100
for _, toolset := range cfg.EnabledToolsets {
101101
if toolset != "all" {
@@ -374,3 +374,69 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro
374374
req.Header.Set("Authorization", "Bearer "+t.token)
375375
return t.transport.RoundTrip(req)
376376
}
377+
378+
// MultiUserHTTPServerConfig holds config for the multi-user HTTP server
379+
// (no global token, per-request tokens)
380+
type MultiUserHTTPServerConfig struct {
381+
Version string
382+
Host string
383+
EnabledToolsets []string
384+
DynamicToolsets bool
385+
ReadOnly bool
386+
Port int
387+
}
388+
389+
// RunMultiUserHTTPServer starts a streamable HTTP server that supports per-request GitHub tokens
390+
func RunMultiUserHTTPServer(cfg MultiUserHTTPServerConfig) error {
391+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
392+
defer stop()
393+
394+
t, _ := translations.TranslationHelper()
395+
396+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
397+
token := extractTokenFromRequest(r)
398+
if token == "" {
399+
w.WriteHeader(http.StatusUnauthorized)
400+
_, _ = w.Write([]byte(`{"error":"missing GitHub token in Authorization header"}`))
401+
return
402+
}
403+
ghServer, err := NewMCPServer(MCPServerConfig{
404+
Version: cfg.Version,
405+
Host: cfg.Host,
406+
Token: token,
407+
EnabledToolsets: cfg.EnabledToolsets,
408+
DynamicToolsets: cfg.DynamicToolsets,
409+
ReadOnly: cfg.ReadOnly,
410+
Translator: t,
411+
})
412+
if err != nil {
413+
w.WriteHeader(http.StatusInternalServerError)
414+
_, _ = w.Write([]byte(`{"error":"failed to create MCP server"}`))
415+
return
416+
}
417+
// Use the MCP server's HTTP handler for this request
418+
mcpHTTP := server.NewStreamableHTTPServer(ghServer)
419+
mcpHTTP.ServeHTTP(w, r)
420+
})
421+
422+
addr := fmt.Sprintf(":%d", cfg.Port)
423+
fmt.Fprintf(os.Stderr, "GitHub MCP Server running in multi-user HTTP mode on %s\n", addr)
424+
server := &http.Server{Addr: addr, Handler: handler}
425+
go func() {
426+
<-ctx.Done()
427+
_ = server.Shutdown(context.Background())
428+
}()
429+
return server.ListenAndServe()
430+
}
431+
432+
// extractTokenFromRequest extracts the GitHub token from the Authorization header
433+
func extractTokenFromRequest(r *http.Request) string {
434+
h := r.Header.Get("Authorization")
435+
if h == "" {
436+
return ""
437+
}
438+
if strings.HasPrefix(h, "Bearer ") {
439+
return strings.TrimPrefix(h, "Bearer ")
440+
}
441+
return h
442+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy