Skip to content
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
21 changes: 21 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ var (
Short: "Start HTTP server",
Long: `Start an HTTP server that listens for MCP requests over HTTP.`,
RunE: func(_ *cobra.Command, _ []string) error {
staticToken, err := resolveHTTPStaticToken(viper.GetBool("static-auth"), viper.GetString("personal_access_token"))
if err != nil {
return err
}

// Parse toolsets (same approach as stdio — see comment there)
var enabledToolsets []string
if viper.IsSet("toolsets") {
Expand Down Expand Up @@ -199,6 +204,7 @@ var (
httpConfig := ghhttp.ServerConfig{
Version: version,
Host: viper.GetString("host"),
StaticToken: staticToken,
Port: viper.GetInt("port"),
ListenHost: viper.GetString("listen-host"),
BaseURL: viper.GetString("base-url"),
Expand Down Expand Up @@ -268,6 +274,7 @@ func init() {
httpCmd.Flags().String("authorization-server", "", "Override the authorization server URL in OAuth resource metadata. Useful when deploying behind an OAuth proxy (e.g. for GHES). Env: GITHUB_AUTHORIZATION_SERVER")
httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses")
httpCmd.Flags().Bool("trust-proxy-headers", false, "Honor X-Forwarded-Host and X-Forwarded-Proto when constructing OAuth resource metadata URLs. Only enable when the server is deployed behind a trusted proxy that sets these headers. Ignored when --base-url is set.")
httpCmd.Flags().Bool("static-auth", false, "Allow requests without an Authorization header to use GITHUB_PERSONAL_ACCESS_TOKEN. For single-tenant deployments behind a trusted access boundary only. Env: GITHUB_STATIC_AUTH")

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
Expand Down Expand Up @@ -297,6 +304,7 @@ func init() {
_ = viper.BindPFlag("authorization-server", httpCmd.Flags().Lookup("authorization-server"))
_ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
_ = viper.BindPFlag("trust-proxy-headers", httpCmd.Flags().Lookup("trust-proxy-headers"))
_ = viper.BindPFlag("static-auth", httpCmd.Flags().Lookup("static-auth"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)
Expand All @@ -309,6 +317,19 @@ func initConfig() {
viper.AutomaticEnv()
}

func resolveHTTPStaticToken(enabled bool, token string) (string, error) {
if !enabled {
return "", nil
}
if token == "" {
return "", errors.New("HTTP static auth requires GITHUB_PERSONAL_ACCESS_TOKEN")
}
if _, err := utils.ParseToken(token); err != nil {
return "", fmt.Errorf("invalid GITHUB_PERSONAL_ACCESS_TOKEN for HTTP static auth: %w", err)
}
return token, nil
}

func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
Expand Down
40 changes: 40 additions & 0 deletions cmd/github-mcp-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,46 @@ func TestAuthorizationServerConfigurationIsHTTPOnly(t *testing.T) {
assert.Equal(t, "https://oauth-proxy.example.com", viper.GetString("authorization-server"))
}

func TestStaticAuthConfigurationIsHTTPOnly(t *testing.T) {
flag := httpCmd.Flags().Lookup("static-auth")
require.NotNil(t, flag)
assert.Equal(t, "false", flag.DefValue)
assert.Nil(t, stdioCmd.Flags().Lookup("static-auth"))

t.Setenv("GITHUB_STATIC_AUTH", "true")
initConfig()
assert.True(t, viper.GetBool("static-auth"))
}

func TestResolveHTTPStaticToken(t *testing.T) {
const validToken = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

t.Run("disabled ignores configured token", func(t *testing.T) {
token, err := resolveHTTPStaticToken(false, validToken)
require.NoError(t, err)
assert.Empty(t, token)
})

t.Run("enabled accepts configured token", func(t *testing.T) {
token, err := resolveHTTPStaticToken(true, validToken)
require.NoError(t, err)
assert.Equal(t, validToken, token)
})

t.Run("enabled rejects missing token", func(t *testing.T) {
_, err := resolveHTTPStaticToken(true, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "GITHUB_PERSONAL_ACCESS_TOKEN")
})

t.Run("enabled rejects invalid token without exposing it", func(t *testing.T) {
const invalidToken = "invalid-secret-value"
_, err := resolveHTTPStaticToken(true, invalidToken)
require.Error(t, err)
assert.NotContains(t, err.Error(), invalidToken)
})
}

func TestWriteToolDocScopes(t *testing.T) {
tool := inventory.ServerTool{
Tool: mcp.Tool{Name: "delete", Annotations: &mcp.ToolAnnotations{Title: "Delete"}},
Expand Down
32 changes: 32 additions & 0 deletions docs/streamable-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,38 @@ github-mcp-server http

The server will be available at `http://localhost:8082`.

### Single-tenant static authentication

By default, every HTTP request must provide its own GitHub token in the
`Authorization` header. For a single-tenant deployment where a trusted gateway
cannot add that header, explicitly opt in to using the process credential when
the header is absent:

```bash
export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_yourtokenhere
# Bind the backend to loopback and put an authenticating gateway in front of it.
github-mcp-server http --static-auth --listen-host 127.0.0.1 --read-only
```

The environment equivalent of the flag is `GITHUB_STATIC_AUTH=true`. If static
authentication is enabled without a valid `GITHUB_PERSONAL_ACCESS_TOKEN`, the
server refuses to start. A request that includes an `Authorization` header
always uses that header instead; an empty, malformed, or unsupported header is
rejected rather than replaced with the process credential.

Browser requests carrying an `Origin` header cannot use the static credential
fallback. They must explicitly request the `Authorization` header during CORS
preflight and send their own token with the actual request.

> [!WARNING]
> Static authentication does not authenticate callers to the MCP server. Any
> caller that can reach the endpoint without an `Authorization` header receives
> the permissions of the shared GitHub credential. Use this mode only behind an
> authenticating trusted gateway or access boundary. Binding to a loopback
> interface reduces network exposure but does not authenticate local callers and
> is not sufficient by itself. Grant the token the least privileges possible,
> and enable `--read-only` unless write tools are required.

### With Scope Challenge

Enable scope validation to enforce GitHub permission checks:
Expand Down
2 changes: 1 addition & 1 deletion pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func (h *Handler) RegisterMiddleware(r chi.Router) {
r.Use(
// Must run first: bounds the body before anything downstream reads it.
middleware.WithMaxBodySize(h.maxRequestBodyBytes()),
middleware.ExtractUserToken(h.oauthCfg),
middleware.ExtractUserTokenWithFallback(h.oauthCfg, h.config.StaticToken),
middleware.WithRequestConfig,
middleware.WithMCPParse(),
middleware.WithPATScopes(h.logger, h.scopeFetcher),
Expand Down
60 changes: 60 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]strin

var _ scopes.FetcherInterface = allScopesFetcher{}

type recordingScopesFetcher struct {
token string
calls int
}

func (f *recordingScopesFetcher) FetchTokenScopes(_ context.Context, token string) ([]string, error) {
f.token = token
f.calls++
return []string{string(scopes.Repo)}, nil
}

var _ scopes.FetcherInterface = (*recordingScopesFetcher)(nil)

func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool {
tool := mockTool(name, toolsetID, readOnly)
tool.FeatureFlagEnable = enableFlag
Expand Down Expand Up @@ -1011,6 +1024,53 @@ func TestCrossOriginProtection(t *testing.T) {
}
}

func TestStaticTokenFallbackFlowsThroughPATScopeMiddleware(t *testing.T) {
const staticToken = "ghp_staticxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
jsonRPCBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`

apiHost, err := utils.NewAPIHost("https://api.github.com")
require.NoError(t, err)

var capturedTokenInfo *ghcontext.TokenInfo
var capturedScopes []string
fetcher := &recordingScopesFetcher{}
handler := NewHTTPMcpHandler(
context.Background(),
&ServerConfig{Version: "test", StaticToken: staticToken},
nil,
translations.NullTranslationHelper,
slog.Default(),
apiHost,
WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) {
return inventory.NewBuilder().Build()
}),
WithGitHubMCPServerFactory(func(r *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context())
capturedScopes, _ = ghcontext.GetTokenScopes(r.Context())
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
}),
WithScopeFetcher(fetcher),
)

router := chi.NewRouter()
handler.RegisterMiddleware(router)
handler.RegisterRoutes(router)

req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonRPCBody))
req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON)
req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", "))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)

assert.Equal(t, http.StatusOK, rr.Code, "unexpected status code; body: %s", rr.Body.String())
require.NotNil(t, capturedTokenInfo)
assert.Equal(t, staticToken, capturedTokenInfo.Token)
assert.Equal(t, utils.TokenTypePersonalAccessToken, capturedTokenInfo.TokenType)
assert.Equal(t, staticToken, fetcher.token)
assert.Equal(t, 1, fetcher.calls)
assert.Equal(t, []string{string(scopes.Repo)}, capturedScopes)
}

func TestHTTPToolMinimumProtocolVersion(t *testing.T) {
apiHost, err := utils.NewAPIHost("https://api.github.com")
require.NoError(t, err)
Expand Down
3 changes: 2 additions & 1 deletion pkg/http/middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import (
// SetCorsHeaders is middleware that sets CORS headers to allow browser-based
// MCP clients to connect from any origin. This is safe because the server
// authenticates via bearer tokens (not cookies), so cross-origin requests
// cannot exploit ambient credentials.
// cannot exploit ambient credentials. Static auth installs its browser guard
// before this middleware.
func SetCorsHeaders(h http.Handler) http.Handler {
allowHeaders := strings.Join([]string{
"Content-Type",
Expand Down
61 changes: 61 additions & 0 deletions pkg/http/middleware/static_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package middleware

import (
"net/http"
"strings"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/http/oauth"
)

// StaticAuthBrowserGuard prevents browser requests from implicitly consuming a
// shared static credential. It must run before CORS response headers are set.
func StaticAuthBrowserGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isOAuthMetadataPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if r.Header.Get("Origin") == "" {
next.ServeHTTP(w, r)
return
}
if _, ok := ghcontext.GetTokenInfo(r.Context()); ok {
next.ServeHTTP(w, r)
return
}

if r.Method == http.MethodOptions {
if headerListContains(r.Header.Get("Access-Control-Request-Headers"), headers.AuthorizationHeader) {
next.ServeHTTP(w, r)
return
}
rejectStaticAuthBrowserRequest(w)
return
}

if hasAuthorizationHeader(r.Header) {
next.ServeHTTP(w, r)
return
}
rejectStaticAuthBrowserRequest(w)
})
}

func isOAuthMetadataPath(path string) bool {
return path == oauth.OAuthProtectedResourcePrefix || strings.HasPrefix(path, oauth.OAuthProtectedResourcePrefix+"/")
}

func headerListContains(value, target string) bool {
for header := range strings.SplitSeq(value, ",") {
if strings.EqualFold(strings.TrimSpace(header), target) {
return true
}
}
return false
}

func rejectStaticAuthBrowserRequest(w http.ResponseWriter) {
http.Error(w, "Forbidden", http.StatusForbidden)
}
Loading