Skip to content

Commit a808385

Browse files
committed
feat: support GitHub App authentication in HTTP mode
The HTTP command now parses the same GITHUB_APP_* environment variables as stdio mode. When configured, a new middleware injects an installation token into the request context, allowing callers to connect without an Authorization header. Also strip a trailing slash from the App auth base URL: APIHost returns "https://api.github.com/", which previously produced a "//app/..." path that GitHub answered with 404.
1 parent 6799ef5 commit a808385

7 files changed

Lines changed: 258 additions & 0 deletions

File tree

cmd/github-mcp-server/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ var (
145145
}
146146
}
147147

148+
appID, privateKey, installationID, err := parseAppAuthConfig()
149+
if err != nil {
150+
return err
151+
}
152+
148153
ttl := viper.GetDuration("repo-access-cache-ttl")
149154
httpConfig := ghhttp.ServerConfig{
150155
Version: version,
@@ -166,6 +171,9 @@ var (
166171
EnabledFeatures: enabledFeatures,
167172
InsidersMode: viper.GetBool("insiders"),
168173
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
174+
AppID: appID,
175+
PrivateKey: privateKey,
176+
InstallationID: installationID,
169177
}
170178

171179
return ghhttp.RunHTTPServer(httpConfig)

pkg/github/appauth/appauth.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"fmt"
1414
"io"
1515
"net/http"
16+
"strings"
1617
"sync"
1718
"time"
1819
)
@@ -67,6 +68,9 @@ func NewTransport(base http.RoundTripper, cfg Config) (*Transport, error) {
6768
if cfg.BaseURL == "" {
6869
cfg.BaseURL = "https://api.github.com"
6970
}
71+
// Why: APIHost.BaseRESTURL returns "https://api.github.com/" (with trailing slash).
72+
// Concatenating that with "/app/installations/..." yields "//app/..." which GitHub returns 404 for.
73+
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
7074
return &Transport{
7175
config: cfg,
7276
key: key,

pkg/github/appauth/appauth_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ func TestNewTransport_CustomBaseURL(t *testing.T) {
132132
assert.Equal(t, "https://github.example.com/api/v3", tr.config.BaseURL)
133133
}
134134

135+
func TestNewTransport_TrimsTrailingSlash(t *testing.T) {
136+
_, pemBytes := generateTestKey(t)
137+
tr, err := NewTransport(nil, Config{
138+
AppID: 123,
139+
PrivateKey: pemBytes,
140+
InstallationID: 456,
141+
BaseURL: "https://api.github.com/",
142+
})
143+
require.NoError(t, err)
144+
assert.Equal(t, "https://api.github.com", tr.config.BaseURL)
145+
}
146+
135147
func TestTransport_GenerateJWT(t *testing.T) {
136148
key, pemBytes := generateTestKey(t)
137149
tr, err := NewTransport(nil, Config{

pkg/http/handler.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
ghcontext "github.com/github/github-mcp-server/pkg/context"
1010
"github.com/github/github-mcp-server/pkg/github"
11+
"github.com/github/github-mcp-server/pkg/github/appauth"
1112
"github.com/github/github-mcp-server/pkg/http/middleware"
1213
"github.com/github/github-mcp-server/pkg/http/oauth"
1314
"github.com/github/github-mcp-server/pkg/inventory"
@@ -36,6 +37,7 @@ type Handler struct {
3637
oauthCfg *oauth.Config
3738
scopeFetcher scopes.FetcherInterface
3839
schemaCache *mcp.SchemaCache
40+
appAuthTransport *appauth.Transport
3941
}
4042

4143
type HandlerOptions struct {
@@ -44,6 +46,7 @@ type HandlerOptions struct {
4446
OAuthConfig *oauth.Config
4547
ScopeFetcher scopes.FetcherInterface
4648
FeatureChecker inventory.FeatureFlagChecker
49+
AppAuthTransport *appauth.Transport
4750
}
4851

4952
type HandlerOption func(*HandlerOptions)
@@ -54,6 +57,16 @@ func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption {
5457
}
5558
}
5659

60+
// WithAppAuthTransport configures the handler to authenticate outbound GitHub
61+
// API calls using a GitHub App installation. When set, an incoming request
62+
// without an Authorization header is allowed: the middleware injects the
63+
// installation token derived from this transport into the request context.
64+
func WithAppAuthTransport(t *appauth.Transport) HandlerOption {
65+
return func(o *HandlerOptions) {
66+
o.AppAuthTransport = t
67+
}
68+
}
69+
5770
func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption {
5871
return func(o *HandlerOptions) {
5972
o.GitHubMcpServerFactory = f
@@ -122,10 +135,14 @@ func NewHTTPMcpHandler(
122135
oauthCfg: opts.OAuthConfig,
123136
scopeFetcher: scopeFetcher,
124137
schemaCache: schemaCache,
138+
appAuthTransport: opts.AppAuthTransport,
125139
}
126140
}
127141

128142
func (h *Handler) RegisterMiddleware(r chi.Router) {
143+
if h.appAuthTransport != nil {
144+
r.Use(middleware.WithGitHubAppToken(h.appAuthTransport, h.logger))
145+
}
129146
r.Use(
130147
middleware.ExtractUserToken(h.oauthCfg),
131148
middleware.WithRequestConfig,

pkg/http/middleware/app_auth.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package middleware
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
7+
ghcontext "github.com/github/github-mcp-server/pkg/context"
8+
"github.com/github/github-mcp-server/pkg/github/appauth"
9+
"github.com/github/github-mcp-server/pkg/utils"
10+
)
11+
12+
// WithGitHubAppToken injects a GitHub App installation token into the request
13+
// context when the incoming request does not already carry one. This lets the
14+
// HTTP server authenticate as a GitHub App installation instead of requiring
15+
// every caller to send an Authorization header.
16+
//
17+
// If the request already carries TokenInfo (e.g., an explicit Authorization
18+
// header parsed earlier), this middleware is a no-op so per-request tokens
19+
// take precedence.
20+
//
21+
// Why: the HTTP server's downstream pipeline (ExtractUserToken,
22+
// RequestDeps.GetClient) is built around a per-request bearer token. Rather
23+
// than rewire that pipeline, we synthesize a TokenInfo from the installation
24+
// token so the existing flow works unchanged.
25+
func WithGitHubAppToken(transport *appauth.Transport, logger *slog.Logger) func(http.Handler) http.Handler {
26+
return func(next http.Handler) http.Handler {
27+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+
ctx := r.Context()
29+
30+
if _, ok := ghcontext.GetTokenInfo(ctx); ok {
31+
next.ServeHTTP(w, r)
32+
return
33+
}
34+
35+
token, err := transport.Token(ctx)
36+
if err != nil {
37+
logger.Error("failed to obtain GitHub App installation token", "error", err)
38+
http.Error(w, "failed to obtain GitHub App installation token", http.StatusInternalServerError)
39+
return
40+
}
41+
42+
ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{
43+
Token: token,
44+
TokenType: utils.TokenTypeServerToServerGitHubAppToken,
45+
})
46+
next.ServeHTTP(w, r.WithContext(ctx))
47+
})
48+
}
49+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package middleware
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/x509"
7+
"encoding/json"
8+
"encoding/pem"
9+
"io"
10+
"log/slog"
11+
"net/http"
12+
"net/http/httptest"
13+
"sync/atomic"
14+
"testing"
15+
"time"
16+
17+
ghcontext "github.com/github/github-mcp-server/pkg/context"
18+
"github.com/github/github-mcp-server/pkg/github/appauth"
19+
"github.com/github/github-mcp-server/pkg/utils"
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func generateAppKey(t *testing.T) []byte {
25+
t.Helper()
26+
key, err := rsa.GenerateKey(rand.Reader, 2048)
27+
require.NoError(t, err)
28+
return pem.EncodeToMemory(&pem.Block{
29+
Type: "RSA PRIVATE KEY",
30+
Bytes: x509.MarshalPKCS1PrivateKey(key),
31+
})
32+
}
33+
34+
func newAppTransport(t *testing.T, baseURL string) *appauth.Transport {
35+
t.Helper()
36+
tr, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{
37+
AppID: 12345,
38+
PrivateKey: generateAppKey(t),
39+
InstallationID: 67890,
40+
BaseURL: baseURL,
41+
})
42+
require.NoError(t, err)
43+
return tr
44+
}
45+
46+
func TestWithGitHubAppToken_InjectsToken(t *testing.T) {
47+
var hits atomic.Int32
48+
ghAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
49+
hits.Add(1)
50+
w.WriteHeader(http.StatusCreated)
51+
_ = json.NewEncoder(w).Encode(map[string]any{
52+
"token": "ghs_injected_token",
53+
"expires_at": time.Now().Add(1 * time.Hour),
54+
})
55+
}))
56+
defer ghAPI.Close()
57+
58+
tr := newAppTransport(t, ghAPI.URL)
59+
60+
var captured *ghcontext.TokenInfo
61+
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
62+
info, _ := ghcontext.GetTokenInfo(r.Context())
63+
captured = info
64+
})
65+
66+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
67+
handler := WithGitHubAppToken(tr, logger)(next)
68+
69+
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
70+
rr := httptest.NewRecorder()
71+
handler.ServeHTTP(rr, req)
72+
73+
assert.Equal(t, http.StatusOK, rr.Code)
74+
require.NotNil(t, captured)
75+
assert.Equal(t, "ghs_injected_token", captured.Token)
76+
assert.Equal(t, utils.TokenTypeServerToServerGitHubAppToken, captured.TokenType)
77+
assert.Equal(t, int32(1), hits.Load())
78+
}
79+
80+
func TestWithGitHubAppToken_PreservesExistingTokenInfo(t *testing.T) {
81+
var hits atomic.Int32
82+
ghAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
83+
hits.Add(1)
84+
w.WriteHeader(http.StatusCreated)
85+
_ = json.NewEncoder(w).Encode(map[string]any{
86+
"token": "ghs_should_not_be_used",
87+
"expires_at": time.Now().Add(1 * time.Hour),
88+
})
89+
}))
90+
defer ghAPI.Close()
91+
92+
tr := newAppTransport(t, ghAPI.URL)
93+
94+
pre := &ghcontext.TokenInfo{
95+
Token: "ghp_explicit",
96+
TokenType: utils.TokenTypePersonalAccessToken,
97+
}
98+
99+
var captured *ghcontext.TokenInfo
100+
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
101+
info, _ := ghcontext.GetTokenInfo(r.Context())
102+
captured = info
103+
})
104+
105+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
106+
handler := WithGitHubAppToken(tr, logger)(next)
107+
108+
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
109+
req = req.WithContext(ghcontext.WithTokenInfo(req.Context(), pre))
110+
rr := httptest.NewRecorder()
111+
handler.ServeHTTP(rr, req)
112+
113+
assert.Equal(t, http.StatusOK, rr.Code)
114+
require.NotNil(t, captured)
115+
assert.Equal(t, "ghp_explicit", captured.Token)
116+
assert.Equal(t, int32(0), hits.Load(), "installation token should not have been fetched")
117+
}
118+
119+
func TestWithGitHubAppToken_PropagatesFetchError(t *testing.T) {
120+
ghAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
121+
http.Error(w, "boom", http.StatusInternalServerError)
122+
}))
123+
defer ghAPI.Close()
124+
125+
tr := newAppTransport(t, ghAPI.URL)
126+
127+
nextCalled := false
128+
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
129+
nextCalled = true
130+
})
131+
132+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
133+
handler := WithGitHubAppToken(tr, logger)(next)
134+
135+
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
136+
rr := httptest.NewRecorder()
137+
handler.ServeHTTP(rr, req)
138+
139+
assert.Equal(t, http.StatusInternalServerError, rr.Code)
140+
assert.False(t, nextCalled, "next handler must not run when token fetch fails")
141+
}

pkg/http/server.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
ghcontext "github.com/github/github-mcp-server/pkg/context"
1515
"github.com/github/github-mcp-server/pkg/github"
16+
"github.com/github/github-mcp-server/pkg/github/appauth"
1617
"github.com/github/github-mcp-server/pkg/http/middleware"
1718
"github.com/github/github-mcp-server/pkg/http/oauth"
1819
"github.com/github/github-mcp-server/pkg/inventory"
@@ -94,6 +95,14 @@ type ServerConfig struct {
9495

9596
// InsidersMode expands to the curated set of feature flags enabled for insiders.
9697
InsidersMode bool
98+
99+
// GitHub App authentication (alternative to per-request bearer tokens).
100+
// When AppID, PrivateKey, and InstallationID are all set, the server
101+
// authenticates outbound GitHub API calls as a GitHub App installation
102+
// instead of requiring an Authorization header on incoming requests.
103+
AppID int64
104+
PrivateKey []byte
105+
InstallationID int64
97106
}
98107

99108
func RunHTTPServer(cfg ServerConfig) error {
@@ -168,6 +177,24 @@ func RunHTTPServer(cfg ServerConfig) error {
168177
serverOptions = append(serverOptions, WithScopeFetcher(scopeFetcher))
169178
}
170179

180+
if cfg.AppID != 0 && len(cfg.PrivateKey) > 0 && cfg.InstallationID != 0 {
181+
baseURL, err := apiHost.BaseRESTURL(ctx)
182+
if err != nil {
183+
return fmt.Errorf("failed to get base REST URL for app auth: %w", err)
184+
}
185+
appTransport, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{
186+
AppID: cfg.AppID,
187+
PrivateKey: cfg.PrivateKey,
188+
InstallationID: cfg.InstallationID,
189+
BaseURL: baseURL.String(),
190+
})
191+
if err != nil {
192+
return fmt.Errorf("failed to create GitHub App auth transport: %w", err)
193+
}
194+
serverOptions = append(serverOptions, WithAppAuthTransport(appTransport))
195+
logger.Info("using GitHub App authentication", "appID", cfg.AppID, "installationID", cfg.InstallationID)
196+
}
197+
171198
r := chi.NewRouter()
172199
handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...)
173200
oauthHandler, err := oauth.NewAuthHandler(oauthCfg, apiHost)

0 commit comments

Comments
 (0)