From 20cc4cefb8d090f720512ac6ce5eaece13765fef Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 16 Jun 2026 19:05:00 +0200 Subject: [PATCH 1/5] feat(oauth): wire stdio OAuth 2.1 login into the server Connect the internal/oauth core library to the stdio MCP server so users can authenticate with an OAuth App or GitHub App client ID instead of a static personal access token. - BearerAuthTransport gains a TokenProvider that is consulted per request, letting the lazily-acquired, auto-refreshing OAuth token take effect without rebuilding the client. - createGitHubClients uses BearerAuthTransport (and skips go-github's WithAuthToken, which would pin a static token) when a TokenProvider is set. - RunStdioServer starts without a token and installs receiving middleware that runs the authorization flow on the first tool call, surfacing the auth URL or device code via elicitation (or a tool result as a fallback). - Tool filtering uses the requested OAuth scopes; the default supported set hides nothing, while a narrower --oauth-scopes both narrows the grant and filters tools accordingly. - A sessionPrompter adapts the MCP server session to oauth.Prompter, keeping the authorization URL off the model's context. - New stdio flags: --oauth-client-id/-client-secret/-scopes/-callback-port. This is stdio-only and deliberately does not touch MCP-HTTP auth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/main.go | 42 +++- internal/ghmcp/oauth.go | 128 ++++++++++++ internal/ghmcp/oauth_test.go | 329 ++++++++++++++++++++++++++++++ internal/ghmcp/server.go | 71 +++++-- pkg/github/server.go | 5 + pkg/http/transport/bearer.go | 12 +- pkg/http/transport/bearer_test.go | 164 +++++++++++++++ 7 files changed, 736 insertions(+), 15 deletions(-) create mode 100644 internal/ghmcp/oauth.go create mode 100644 internal/ghmcp/oauth_test.go create mode 100644 pkg/http/transport/bearer_test.go diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 604556692..b329b5012 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -8,8 +8,10 @@ import ( "time" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" ghhttp "github.com/github/github-mcp-server/pkg/http" + ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -34,8 +36,9 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + oauthClientID := viper.GetString("oauth-client-id") + if token == "" && oauthClientID == "" { + return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -95,6 +98,29 @@ var ( ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, } + + // When no static token is provided, log in via OAuth using the given + // client. The requested scopes default to the full supported set + // (which filters out no tools); an explicit, narrower --oauth-scopes + // both narrows the grant and hides tools needing other scopes. + if token == "" { + scopes := ghoauth.SupportedScopes + if viper.IsSet("oauth-scopes") { + if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { + return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err) + } + } + oauthConfig := oauth.NewGitHubConfig( + oauthClientID, + viper.GetString("oauth-client-secret"), + scopes, + viper.GetString("host"), + viper.GetInt("oauth-callback-port"), + ) + stdioServerConfig.OAuthManager = oauth.NewManager(oauthConfig, nil) + stdioServerConfig.OAuthScopes = scopes + } + return ghmcp.RunStdioServer(stdioServerConfig) }, } @@ -183,6 +209,14 @@ func init() { rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token) + // to log in via the browser-based OAuth flow on first use. Works for both + // OAuth Apps and GitHub Apps. + stdioCmd.Flags().String("oauth-client-id", "", "OAuth App or GitHub App client ID, enabling interactive OAuth login when no token is set") + stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret, if the app requires one (it is a public, non-confidential credential for distributed clients)") + stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") + stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker") + // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") @@ -205,6 +239,10 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id")) + _ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret")) + _ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes")) + _ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go new file mode 100644 index 000000000..6a1d38895 --- /dev/null +++ b/internal/ghmcp/oauth.go @@ -0,0 +1,128 @@ +package ghmcp + +import ( + "context" + "crypto/rand" + "fmt" + "log/slog" + + "github.com/github/github-mcp-server/internal/oauth" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// sessionPrompter adapts an MCP server session to oauth.Prompter, presenting +// authorization prompts to the user via elicitation. Keeping the prompt on the +// MCP control channel (rather than a tool result) keeps the authorization URL +// and any session-bound state out of the model's context. +type sessionPrompter struct { + session *mcp.ServerSession +} + +// elicitationCaps returns the client's declared elicitation capabilities, or nil +// if the client did not advertise any. +func (p *sessionPrompter) elicitationCaps() *mcp.ElicitationCapabilities { + params := p.session.InitializeParams() + if params == nil || params.Capabilities == nil { + return nil + } + return params.Capabilities.Elicitation +} + +// CanPromptURL reports whether the client supports URL-mode elicitation. +func (p *sessionPrompter) CanPromptURL() bool { + caps := p.elicitationCaps() + return caps != nil && caps.URL != nil +} + +// PromptURL presents the authorization URL via URL-mode elicitation and blocks +// until the user acknowledges, declines, or ctx is done. +func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) error { + res, err := p.session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "url", + Message: prompt.Message, + URL: prompt.URL, + ElicitationID: rand.Text(), + }) + if err != nil { + return err + } + if res.Action != "accept" { + return oauth.ErrPromptDeclined + } + return nil +} + +// CanPromptForm reports whether the client supports form-mode elicitation. The +// SDK treats a client that advertises neither form nor URL capabilities as +// supporting forms, for backward compatibility, so we mirror that here. +func (p *sessionPrompter) CanPromptForm() bool { + caps := p.elicitationCaps() + if caps == nil { + return false + } + return caps.Form != nil || caps.URL == nil +} + +// PromptForm presents a textual acknowledgement (used to display a device code +// when URL elicitation is unavailable) and blocks until the user responds. +func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) error { + res, err := p.session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "form", + Message: prompt.Message, + }) + if err != nil { + return err + } + if res.Action != "accept" { + return oauth.ErrPromptDeclined + } + return nil +} + +// oauthAuthenticator is the subset of *oauth.Manager that the middleware needs. +// Depending on the interface (rather than the concrete manager) lets the +// middleware be exercised with a deterministic fake, since driving the real +// manager to its branches would require standing up live GitHub flows. +type oauthAuthenticator interface { + HasToken() bool + Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) +} + +// createOAuthMiddleware returns receiving middleware that authorizes the session +// lazily, on the first tool call. Authorization is deferred until here (rather +// than at startup) because the prompts depend on an initialized session whose +// elicitation capabilities are known. +// +// When a token is already available the call proceeds untouched. Otherwise the +// flow runs: secure channels (browser, URL elicitation) block until the token +// arrives and then the call proceeds; the last-resort channel returns the +// instruction to the user as a tool result and asks them to retry. +func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method != "tools/call" || mgr.HasToken() { + return next(ctx, method, request) + } + + callReq, ok := request.(*mcp.CallToolRequest) + if !ok { + return next(ctx, method, request) + } + + outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session}) + if err != nil { + return nil, fmt.Errorf("github authorization failed: %w", err) + } + if outcome != nil && outcome.UserAction != nil { + logger.Info("surfacing github authorization instructions to user") + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}}, + }, nil + } + return next(ctx, method, request) + } + } +} + +// ensure sessionPrompter satisfies the Prompter contract. +var _ oauth.Prompter = (*sessionPrompter)(nil) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go new file mode 100644 index 000000000..4f370cf7b --- /dev/null +++ b/internal/ghmcp/oauth_test.go @@ -0,0 +1,329 @@ +package ghmcp + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/github/github-mcp-server/internal/oauth" + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// probeToolName is the name of the throwaway tool the harness registers; its +// handler runs a probe closure against a sessionPrompter so the adapter can be +// exercised against a real, fully-negotiated server session from the client side. +const probeToolName = "probe" + +// runProbe stands up an in-memory MCP client/server pair, registers a tool whose +// handler runs probe against a sessionPrompter wrapping the live server session, +// and returns the text the probe produced. The client is configured with the +// given capabilities and elicitation handler so the adapter sees a real, +// fully-negotiated session rather than a hand-built fake. +func runProbe( + t *testing.T, + clientCaps *mcp.ClientCapabilities, + elicitationHandler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error), + probe func(context.Context, *sessionPrompter) string, +) string { + t.Helper() + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: probeToolName}, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + text := probe(ctx, &sessionPrompter{session: req.Session}) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, nil, nil + }) + + st, ct := mcp.NewInMemoryTransports() + + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: clientCaps, + ElicitationHandler: elicitationHandler, + }) + cs, err := client.Connect(context.Background(), ct, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + require.Len(t, res.Content, 1) + text, ok := res.Content[0].(*mcp.TextContent) + require.True(t, ok, "probe result should be text content") + return text.Text +} + +func TestSessionPrompterCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caps *mcp.ClientCapabilities + wantURL bool + wantForm bool + }{ + { + name: "no elicitation advertised", + caps: &mcp.ClientCapabilities{}, + wantURL: false, + wantForm: false, + }, + { + name: "url only", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + wantURL: true, + wantForm: false, + }, + { + name: "form only", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}}, + wantURL: false, + wantForm: true, + }, + { + name: "url and form", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}, Form: &mcp.FormElicitationCapabilities{}}}, + wantURL: true, + wantForm: true, + }, + { + name: "empty elicitation capability implies form for backward compatibility", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}}, + wantURL: false, + wantForm: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := runProbe(t, tc.caps, nil, func(_ context.Context, p *sessionPrompter) string { + if p.CanPromptURL() { + if p.CanPromptForm() { + return "url+form" + } + return "url" + } + if p.CanPromptForm() { + return "form" + } + return "none" + }) + + want := "none" + switch { + case tc.wantURL && tc.wantForm: + want = "url+form" + case tc.wantURL: + want = "url" + case tc.wantForm: + want = "form" + } + assert.Equal(t, want, got) + }) + } +} + +func TestSessionPrompterPromptActions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action string + wantDecline bool + }{ + {name: "accept", action: "accept", wantDecline: false}, + {name: "decline", action: "decline", wantDecline: true}, + {name: "cancel", action: "cancel", wantDecline: true}, + } + + caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{ + URL: &mcp.URLElicitationCapabilities{}, + Form: &mcp.FormElicitationCapabilities{}, + }} + + for _, tc := range tests { + // URL and form modes share the accept/decline mapping; cover both. + for _, mode := range []string{"url", "form"} { + t.Run(tc.name+"/"+mode, func(t *testing.T) { + t.Parallel() + + handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: tc.action}, nil + } + + got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string { + var err error + if mode == "url" { + err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"}) + } else { + err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"}) + } + if err == nil { + return "ok" + } + if err == oauth.ErrPromptDeclined { + return "declined" + } + return "error: " + err.Error() + }) + + if tc.wantDecline { + assert.Equal(t, "declined", got) + } else { + assert.Equal(t, "ok", got) + } + }) + } + } +} + +// fakeAuthenticator is a deterministic stand-in for *oauth.Manager that lets the +// middleware be tested at each branch without standing up live GitHub flows. +type fakeAuthenticator struct { + hasToken bool + outcome *oauth.Outcome + err error + authCalls int + lastPrompter oauth.Prompter +} + +func (f *fakeAuthenticator) HasToken() bool { return f.hasToken } + +func (f *fakeAuthenticator) Authenticate(_ context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) { + f.authCalls++ + f.lastPrompter = prompter + return f.outcome, f.err +} + +func TestCreateOAuthMiddleware(t *testing.T) { + t.Parallel() + + const nextText = "handler-ran" + newNext := func(called *bool) mcp.MethodHandler { + return func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { + *called = true + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: nextText}}}, nil + } + } + + t.Run("non tool call passes through without authenticating", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: false} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), "initialize", &mcp.InitializeRequest{}) + require.NoError(t, err) + assert.True(t, called, "next should run") + assert.Zero(t, fake.authCalls, "authentication must not run for non tool calls") + }) + + t.Run("existing token short circuits authentication", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: true} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, called, "next should run") + assert.Zero(t, fake.authCalls, "authentication must be skipped when a token already exists") + }) + + t.Run("successful authentication proceeds to handler", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: false, outcome: nil, err: nil} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + res, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.NoError(t, err) + assert.Equal(t, 1, fake.authCalls) + assert.True(t, called, "next should run once authorized") + callRes, ok := res.(*mcp.CallToolResult) + require.True(t, ok) + require.Len(t, callRes.Content, 1) + assert.Equal(t, nextText, callRes.Content[0].(*mcp.TextContent).Text) + }) + + t.Run("pending user action is surfaced as a tool result", func(t *testing.T) { + t.Parallel() + const message = "Open https://example.com/auth to authorize, then retry." + fake := &fakeAuthenticator{hasToken: false, outcome: &oauth.Outcome{UserAction: &oauth.UserAction{Message: message}}} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + res, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.NoError(t, err) + assert.False(t, called, "next must not run while the user still needs to authorize") + callRes, ok := res.(*mcp.CallToolResult) + require.True(t, ok) + require.Len(t, callRes.Content, 1) + assert.Equal(t, message, callRes.Content[0].(*mcp.TextContent).Text) + }) + + t.Run("authentication error is returned", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: false, err: assert.AnError} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) + assert.False(t, called, "next must not run when authentication fails") + }) +} + +// TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a +// TokenProvider is configured the REST client authenticates with the provider's +// current token on every request (and never pins a stale one), which is what the +// lazy, refreshing OAuth token depends on. +func TestCreateGitHubClientsTokenProvider(t *testing.T) { + t.Parallel() + + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get(headers.AuthorizationHeader) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + current := "" + apiHost, err := utils.NewAPIHost(server.URL) + require.NoError(t, err) + + clients, err := createGitHubClients(github.MCPServerConfig{ + Version: "test", + TokenProvider: func() string { return current }, + }, apiHost) + require.NoError(t, err) + + do := func() { + resp, err := clients.rest.Client().Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + } + + do() + assert.Equal(t, "Bearer", gotAuth, "no token before authorization") + + current = "oauth-token" + do() + assert.Equal(t, "Bearer oauth-token", gotAuth, "provider token used once available") + + current = "refreshed-token" + do() + assert.Equal(t, "Bearer refreshed-token", gotAuth, "refreshed provider token used") +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a37c4d940..2364b0268 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/transport" @@ -61,16 +62,30 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - // Construct REST client + // Construct REST client. When a TokenProvider is configured (OAuth), we + // authenticate via BearerAuthTransport and skip go-github's WithAuthToken: + // the latter installs its own round tripper that would pin the static token + // and shadow the dynamic one. restUATransport := &transport.UserAgentTransport{ Transport: http.DefaultTransport, Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), } - restClient, err := gogithub.NewClient( - gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), - gogithub.WithAuthToken(cfg.Token), - gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), - ) + var restClient *gogithub.Client + if cfg.TokenProvider != nil { + restClient, err = gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{ + Transport: restUATransport, + TokenProvider: cfg.TokenProvider, + }}), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + } else { + restClient, err = gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithAuthToken(cfg.Token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + } if err != nil { return nil, fmt.Errorf("failed to create REST client: %w", err) } @@ -82,7 +97,8 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv Transport: &transport.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, - Token: cfg.Token, + Token: cfg.Token, + TokenProvider: cfg.TokenProvider, }, } @@ -229,6 +245,18 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + + // OAuthManager, when non-nil, enables OAuth 2.1 login for stdio mode. The + // server starts without a token and runs the authorization flow on the + // first tool call (see createOAuthMiddleware). It is mutually exclusive with + // a static Token. + OAuthManager *oauth.Manager + + // OAuthScopes are the scopes requested during OAuth login. They double as + // the scope set for tool filtering: tools requiring a scope outside this set + // are hidden. The default set is the full supported list, which hides + // nothing; an explicit, narrower list filters accordingly. + OAuthScopes []string } // RunStdioServer is not concurrent safe. @@ -255,11 +283,13 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // Fetch token scopes for scope-based tool filtering (PAT tokens only) - // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. - // Fine-grained PATs and other token types don't support this, so we skip filtering. + // Determine the scope set used to filter tools. Classic PATs expose their + // granted scopes via the API; OAuth uses the requested scopes (the default + // set hides nothing, a narrower explicit set filters accordingly). Other + // token types don't advertise scopes, so filtering is skipped. var tokenScopes []string - if strings.HasPrefix(cfg.Token, "ghp_") { + switch { + case strings.HasPrefix(cfg.Token, "ghp_"): fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) if err != nil { logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) @@ -267,10 +297,20 @@ func RunStdioServer(cfg StdioServerConfig) error { tokenScopes = fetchedScopes logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } - } else { + case cfg.OAuthManager != nil: + tokenScopes = cfg.OAuthScopes + logger.Info("using requested OAuth scopes for tool filtering", "scopes", tokenScopes) + default: logger.Debug("skipping scope filtering for non-PAT token") } + // For OAuth, the token is resolved lazily: empty until the user authorizes + // on the first tool call, then refreshed for the rest of the session. + var tokenProvider func() string + if cfg.OAuthManager != nil { + tokenProvider = cfg.OAuthManager.AccessToken + } + ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, @@ -287,11 +327,18 @@ func RunStdioServer(cfg StdioServerConfig) error { Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, + TokenProvider: tokenProvider, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } + // With OAuth, intercept tool calls to run the authorization flow on first + // use, before the handler tries to call GitHub with an empty token. + if cfg.OAuthManager != nil { + ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger)) + } + if cfg.ExportTranslations { // Once server is initialized, all translations are loaded dumpTranslations() diff --git a/pkg/github/server.go b/pkg/github/server.go index 7ec5837c3..627cc678b 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -68,6 +68,11 @@ type MCPServerConfig struct { // This is used for PAT scope filtering where we can't issue scope challenges. TokenScopes []string + // TokenProvider, when non-nil, supplies the GitHub token for each API + // request instead of the static Token. It backs OAuth login, where the + // token is obtained lazily on first use and refreshed thereafter. + TokenProvider func() string + // Additional server options to apply ServerOptions []MCPServerOption } diff --git a/pkg/http/transport/bearer.go b/pkg/http/transport/bearer.go index 66922bbda..9be3fd534 100644 --- a/pkg/http/transport/bearer.go +++ b/pkg/http/transport/bearer.go @@ -11,11 +11,21 @@ import ( type BearerAuthTransport struct { Transport http.RoundTripper Token string + + // TokenProvider, when non-nil, supplies the bearer token for each request + // and takes precedence over Token. It backs OAuth, where the token is + // obtained after the client is built and is refreshed over the session's + // lifetime. It may return an empty string before authorization completes. + TokenProvider func() string } func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) - req.Header.Set(headers.AuthorizationHeader, "Bearer "+t.Token) + token := t.Token + if t.TokenProvider != nil { + token = t.TokenProvider() + } + req.Header.Set(headers.AuthorizationHeader, "Bearer "+token) // Check for GraphQL-Features in context and add header if present if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 { diff --git a/pkg/http/transport/bearer_test.go b/pkg/http/transport/bearer_test.go new file mode 100644 index 000000000..550144b86 --- /dev/null +++ b/pkg/http/transport/bearer_test.go @@ -0,0 +1,164 @@ +package transport + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBearerAuthTransport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + tokenProvider func() string + wantAuth string + }{ + { + name: "static token", + token: "static-token", + wantAuth: "Bearer static-token", + }, + { + name: "token provider takes precedence over static token", + token: "static-token", + tokenProvider: func() string { return "provided-token" }, + wantAuth: "Bearer provided-token", + }, + { + name: "token provider with empty static token", + tokenProvider: func() string { return "provided-token" }, + wantAuth: "Bearer provided-token", + }, + { + name: "token provider may return empty before authorization", + tokenProvider: func() string { return "" }, + wantAuth: "Bearer", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get(headers.AuthorizationHeader) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + rt := &BearerAuthTransport{ + Transport: http.DefaultTransport, + Token: tc.token, + TokenProvider: tc.tokenProvider, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tc.wantAuth, gotAuth) + }) + } +} + +// TestBearerAuthTransport_TokenProviderResolvedPerRequest verifies that the +// token provider is consulted on every request, so a token that arrives (or is +// refreshed) after the transport is constructed takes effect without rebuilding +// the client. This is the property OAuth relies on. +func TestBearerAuthTransport_TokenProviderResolvedPerRequest(t *testing.T) { + t.Parallel() + + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get(headers.AuthorizationHeader) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + current := "" + rt := &BearerAuthTransport{ + Transport: http.DefaultTransport, + TokenProvider: func() string { return current }, + } + + do := func() { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + } + + do() + assert.Equal(t, "Bearer", gotAuth, "no token yet before authorization") + + current = "first-token" + do() + assert.Equal(t, "Bearer first-token", gotAuth, "token picked up once available") + + current = "refreshed-token" + do() + assert.Equal(t, "Bearer refreshed-token", gotAuth, "refreshed token picked up") +} + +func TestBearerAuthTransport_PassesGraphQLFeaturesHeader(t *testing.T) { + t.Parallel() + + var gotFeatures string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotFeatures = r.Header.Get(headers.GraphQLFeaturesHeader) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + rt := &BearerAuthTransport{ + Transport: http.DefaultTransport, + Token: "token", + } + + ctx := ghcontext.WithGraphQLFeatures(context.Background(), "feature1", "feature2") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "feature1, feature2", gotFeatures) +} + +func TestBearerAuthTransport_DoesNotMutateOriginalRequest(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + rt := &BearerAuthTransport{ + Transport: http.DefaultTransport, + Token: "token", + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Empty(t, req.Header.Get(headers.AuthorizationHeader), "original request must not be mutated") +} From 622d429004373b3f508ed1ccbef164a1c5973e84 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 16 Jun 2026 19:14:33 +0200 Subject: [PATCH 2/5] =?UTF-8?q?refactor(oauth):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20omit=20empty=20bearer=20header,=20guard=20token/oau?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BearerAuthTransport omits the Authorization header entirely when the token is empty (pre-authorization) rather than sending an empty "Bearer " value. - RunStdioServer rejects the ambiguous combination of a static Token and an OAuthManager up front, enforcing the documented mutual exclusivity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghmcp/oauth_test.go | 18 +++++++++++++++++- internal/ghmcp/server.go | 7 +++++++ pkg/http/transport/bearer.go | 6 +++++- pkg/http/transport/bearer_test.go | 4 ++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 4f370cf7b..826b00018 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -286,6 +286,22 @@ func TestCreateOAuthMiddleware(t *testing.T) { }) } +// TestRunStdioServerRejectsTokenAndOAuth verifies the mutually-exclusive guard: +// supplying both a static token and an OAuth manager is rejected before the +// server starts, rather than silently preferring one for auth and the other for +// scope filtering. +func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) { + t.Parallel() + + mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger()) + err := RunStdioServer(StdioServerConfig{ + Token: "ghp_static", + OAuthManager: mgr, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + // TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a // TokenProvider is configured the REST client authenticates with the provider's // current token on every request (and never pins a stale one), which is what the @@ -317,7 +333,7 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) { } do() - assert.Equal(t, "Bearer", gotAuth, "no token before authorization") + assert.Equal(t, "", gotAuth, "no auth header before authorization") current = "oauth-token" do() diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 2364b0268..1bf84453c 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -261,6 +261,13 @@ type StdioServerConfig struct { // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { + // OAuth login and a static token are mutually exclusive: they would + // disagree on how the token is sourced (lazy provider vs. static) and on + // scope filtering, so reject the ambiguous combination up front. + if cfg.OAuthManager != nil && cfg.Token != "" { + return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") + } + // Create app context ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/pkg/http/transport/bearer.go b/pkg/http/transport/bearer.go index 9be3fd534..0c12ddfc9 100644 --- a/pkg/http/transport/bearer.go +++ b/pkg/http/transport/bearer.go @@ -25,7 +25,11 @@ func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro if t.TokenProvider != nil { token = t.TokenProvider() } - req.Header.Set(headers.AuthorizationHeader, "Bearer "+token) + // Before OAuth authorization completes the token is empty; send an + // unauthenticated request rather than an empty "Bearer " header. + if token != "" { + req.Header.Set(headers.AuthorizationHeader, "Bearer "+token) + } // Check for GraphQL-Features in context and add header if present if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 { diff --git a/pkg/http/transport/bearer_test.go b/pkg/http/transport/bearer_test.go index 550144b86..76ef8686c 100644 --- a/pkg/http/transport/bearer_test.go +++ b/pkg/http/transport/bearer_test.go @@ -41,7 +41,7 @@ func TestBearerAuthTransport(t *testing.T) { { name: "token provider may return empty before authorization", tokenProvider: func() string { return "" }, - wantAuth: "Bearer", + wantAuth: "", }, } @@ -103,7 +103,7 @@ func TestBearerAuthTransport_TokenProviderResolvedPerRequest(t *testing.T) { } do() - assert.Equal(t, "Bearer", gotAuth, "no token yet before authorization") + assert.Equal(t, "", gotAuth, "no auth header before authorization") current = "first-token" do() From 2b4d5e60a67c7bc54821102e80fe8204fa314bb0 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 18 Jun 2026 10:57:54 +0200 Subject: [PATCH 3/5] docs(oauth): clarify SupportedScopes is the stdio default and tool filter Document that stdio OAuth login requests these scopes by default and then filters the exposed tools to the scopes actually granted, so a tool whose required scope is absent from this list is hidden under default OAuth even though a PAT carrying that scope would expose it. Keep the list in sync with tool scope requirements when scopes change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/http/oauth/oauth.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go index ffa7669a9..f7ffe67e6 100644 --- a/pkg/http/oauth/oauth.go +++ b/pkg/http/oauth/oauth.go @@ -19,7 +19,13 @@ const ( OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource" ) -// SupportedScopes lists all OAuth scopes that may be required by MCP tools. +// SupportedScopes lists every OAuth scope that an MCP tool may require. It is the +// source of truth in two places: HTTP mode advertises it as scopes_supported in +// the protected-resource metadata, and stdio OAuth login requests it by default +// and then filters the exposed tools to the granted scopes. A tool whose required +// scope is absent here is therefore hidden under default OAuth even though a PAT +// carrying that scope would expose it, so keep this list in sync with tool scope +// requirements when scopes change. var SupportedScopes = []string{ "repo", "read:org", From b7e81b854e05b8e797eef4b9371b2fd1cbeba059 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 25 Jun 2026 21:25:50 +0200 Subject: [PATCH 4/5] Distinguish undeliverable auth prompts from user declines An elicitation prompt that the client cannot deliver (a transport or protocol failure) was treated the same as a user actively declining: any display error cancelled the flow. That conflated a system failure with a deliberate "no", so a client that advertised URL elicitation but failed to deliver it would hard-fail the login instead of degrading. Add an ErrPromptUnavailable sentinel alongside ErrPromptDeclined and have the MCP adapter return it when Elicit fails at the transport level. The manager now falls back to the manual user-action channel on an undeliverable prompt (keeping the background flow alive so the user can still authorize out of band), while a genuine decline still aborts. A context-cancelled prompt is checked first so an ending flow is never misread as a transport failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghmcp/oauth.go | 9 ++++-- internal/ghmcp/oauth_test.go | 46 +++++++++++++++++++++++++++++ internal/oauth/flow.go | 54 +++++++++++++++++++++------------- internal/oauth/manager.go | 52 +++++++++++++++++++++++++++++--- internal/oauth/manager_test.go | 36 +++++++++++++++++++++++ internal/oauth/prompter.go | 18 +++++++++--- 6 files changed, 185 insertions(+), 30 deletions(-) diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go index 6a1d38895..abc6d3d11 100644 --- a/internal/ghmcp/oauth.go +++ b/internal/ghmcp/oauth.go @@ -44,7 +44,10 @@ func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) er ElicitationID: rand.Text(), }) if err != nil { - return err + // The client advertised URL elicitation but the request itself failed: + // classify it as undeliverable (not a user decision) so the flow can fall + // back to a channel that needs no client capability. + return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err) } if res.Action != "accept" { return oauth.ErrPromptDeclined @@ -71,7 +74,9 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e Message: prompt.Message, }) if err != nil { - return err + // As with PromptURL, a delivery failure is undeliverable rather than a + // decline, so the flow can fall back instead of aborting. + return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err) } if res.Action != "accept" { return oauth.ErrPromptDeclined diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 826b00018..732d080e4 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -2,6 +2,7 @@ package ghmcp import ( "context" + "errors" "io" "log/slog" "net/http" @@ -193,6 +194,51 @@ func TestSessionPrompterPromptActions(t *testing.T) { } } +// TestSessionPrompterTransportError verifies that a prompt which fails to be +// delivered (the client errors instead of returning an action) is reported as +// ErrPromptUnavailable, not ErrPromptDeclined. The manager relies on this +// distinction to fall back to manual instructions instead of aborting. +func TestSessionPrompterTransportError(t *testing.T) { + t.Parallel() + + caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{ + URL: &mcp.URLElicitationCapabilities{}, + Form: &mcp.FormElicitationCapabilities{}, + }} + + for _, mode := range []string{"url", "form"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() + + handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return nil, errors.New("client cannot deliver elicitation") + } + + got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string { + var err error + if mode == "url" { + err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"}) + } else { + err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"}) + } + switch { + case err == nil: + return "ok" + case errors.Is(err, oauth.ErrPromptDeclined): + return "declined" + case errors.Is(err, oauth.ErrPromptUnavailable): + return "unavailable" + default: + return "error: " + err.Error() + } + }) + + assert.Equal(t, "unavailable", got, + "a delivery failure must be classified as undeliverable, not a decline") + }) + } +} + // fakeAuthenticator is a deterministic stand-in for *oauth.Manager that lets the // middleware be tested at each branch without standing up live GitHub flows. type fakeAuthenticator struct { diff --git a/internal/oauth/flow.go b/internal/oauth/flow.go index 76d029895..fda1dda19 100644 --- a/internal/oauth/flow.go +++ b/internal/oauth/flow.go @@ -24,9 +24,15 @@ type flowPlan struct { // poll the device endpoint) and returns the token. run func(context.Context) (*oauth2.Token, error) // display, if set, presents the prompt to the user via the Prompter and - // blocks until they act. A non-nil error (including ErrPromptDeclined) - // aborts the flow. + // blocks until they act. ErrPromptDeclined (the user said no) or any other + // error aborts the flow, except ErrPromptUnavailable, which degrades to + // fallback when that is set. display func(context.Context) error + // fallback, if set alongside display, is the manual user action to surface + // when the display prompt cannot be delivered (ErrPromptUnavailable). It lets + // a runtime elicitation failure degrade to the manual channel — keeping the + // background flow alive — instead of aborting. + fallback *UserAction // userAction, if set, indicates the last-resort channel: the caller must // surface it and the user retries after authorizing out of band. userAction *UserAction @@ -124,6 +130,16 @@ func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) { m.logger.Debug("browser auto-open unavailable", "reason", browserErr) } + // The manual instructions double as the fallback if a chosen display channel + // turns out to be undeliverable at runtime, so build them once here. + manual := &UserAction{ + URL: authURL, + Message: fmt.Sprintf( + "To authorize the GitHub MCP Server, open this URL in your browser:\n\n%s\n\nAfter authorizing, retry your request.\n\n%s", + authURL, securityAdvisory, + ), + } + if canPromptURL(prompter) { display := func(ctx context.Context) error { return prompter.PromptURL(ctx, Prompt{ @@ -131,16 +147,10 @@ func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) { URL: authURL, }) } - return &flowPlan{run: run, display: display}, nil + return &flowPlan{run: run, display: display, fallback: manual}, nil } - return &flowPlan{run: run, userAction: &UserAction{ - URL: authURL, - Message: fmt.Sprintf( - "To authorize the GitHub MCP Server, open this URL in your browser:\n\n%s\n\nAfter authorizing, retry your request.\n\n%s", - authURL, securityAdvisory, - ), - }}, nil + return &flowPlan{run: run, userAction: manual}, nil } // beginDevice prepares the device authorization flow. It requests a device code @@ -164,6 +174,17 @@ func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) { return tok, nil } + // As with PKCE, the manual instructions double as the runtime fallback, so + // build them once and reuse for both display plans and the last resort. + manual := &UserAction{ + URL: da.VerificationURI, + UserCode: da.UserCode, + Message: fmt.Sprintf( + "%s\n\nAfter authorizing, retry your request.\n\n%s", + deviceInstruction(da), securityAdvisory, + ), + } + if canPromptURL(prompter) { display := func(ctx context.Context) error { return prompter.PromptURL(ctx, Prompt{ @@ -172,7 +193,7 @@ func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) { UserCode: da.UserCode, }) } - return &flowPlan{run: run, display: display}, nil + return &flowPlan{run: run, display: display, fallback: manual}, nil } if canPromptForm(prompter) { @@ -183,17 +204,10 @@ func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) { UserCode: da.UserCode, }) } - return &flowPlan{run: run, display: display}, nil + return &flowPlan{run: run, display: display, fallback: manual}, nil } - return &flowPlan{run: run, userAction: &UserAction{ - URL: da.VerificationURI, - UserCode: da.UserCode, - Message: fmt.Sprintf( - "%s\n\nAfter authorizing, retry your request.\n\n%s", - deviceInstruction(da), securityAdvisory, - ), - }}, nil + return &flowPlan{run: run, userAction: manual}, nil } // securityAdvisory nudges users on clients without URL elicitation to ask their diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go index 8d1fe0f30..a78e919df 100644 --- a/internal/oauth/manager.go +++ b/internal/oauth/manager.go @@ -190,14 +190,32 @@ func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome } // runFlow executes a prepared flow in the background and records the result. The -// optional display prompt runs concurrently; if it ends in error or decline it -// cancels the flow. +// optional display prompt runs concurrently: a decline (or other failure) aborts +// the flow, while an undeliverable prompt degrades to the manual fallback without +// tearing the flow down, so the user can still authorize out of band. func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan *flowPlan) { defer cancel() if plan.display != nil { go func() { - if err := plan.display(ctx); err != nil { + err := plan.display(ctx) + switch { + case err == nil: + // Prompt shown; the flow completes when the token arrives. + case ctx.Err() != nil: + // The flow is already ending (timed out or cancelled elsewhere), + // so there is nothing to fall back to. Checking this before the + // fallback also prevents misreading a context-cancelled prompt as + // a transport failure. + case errors.Is(err, ErrPromptUnavailable) && plan.fallback != nil: + // The client advertised the capability but could not deliver the + // prompt. Surface the manual instructions instead of failing, and + // keep the background flow alive so the user can still authorize. + m.logger.Debug("authorization prompt undeliverable; falling back to manual instructions", "reason", err) + m.fallBackToUserAction(plan.fallback) + default: + // A user decline (ErrPromptDeclined) or any other prompt failure + // ends the flow. m.logger.Debug("authorization prompt closed", "reason", err) cancel() } @@ -208,6 +226,26 @@ func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan * m.complete(tok, err) } +// fallBackToUserAction promotes a running secure flow to the manual user-action +// channel after its prompt could not be delivered. The background flow keeps +// running, so the user can complete authorization out of band and retry. It is a +// no-op if the flow has already resolved. +func (m *Manager) fallBackToUserAction(ua *UserAction) { + m.mu.Lock() + defer m.mu.Unlock() + if m.status != statusInProgress { + return + } + m.status = statusAwaitingUser + m.pending = ua + // Wake any callers joined on this flow so they receive the action, and clear + // done so complete() does not double-close it when run() later finishes. + if m.done != nil { + close(m.done) + m.done = nil + } +} + // complete records the flow result, installing a refreshing token source on // success, and wakes any joined callers. func (m *Manager) complete(tok *oauth2.Token, err error) { @@ -236,7 +274,9 @@ func (m *Manager) complete(tok *oauth2.Token, err error) { } } -// joinWait blocks until the running flow finishes or ctx is cancelled. +// joinWait blocks until the running flow finishes or ctx is cancelled. If the +// flow was promoted to the manual channel while waiting (its prompt could not be +// delivered), it returns that user action rather than an error. func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, error) { select { case <-done: @@ -244,8 +284,12 @@ func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, e return nil, nil } m.mu.Lock() + pending := m.pending err := m.lastErr m.mu.Unlock() + if pending != nil { + return &Outcome{UserAction: pending}, nil + } if err != nil { return nil, err } diff --git a/internal/oauth/manager_test.go b/internal/oauth/manager_test.go index fb8323246..6f43c03ef 100644 --- a/internal/oauth/manager_test.go +++ b/internal/oauth/manager_test.go @@ -117,6 +117,42 @@ func TestAuthenticateDeclinedPromptFails(t *testing.T) { assert.Empty(t, m.AccessToken()) } +func TestAuthenticateUndeliverablePromptFallsBack(t *testing.T) { + f := newFakeGitHub(t) + m := newManager(t, f) + m.openURL = func(string) error { return errors.New("no browser") } + + // The client advertised URL elicitation but delivering the prompt fails (a + // transport/protocol error, not a user decision). This must degrade to the + // manual instructions rather than aborting like a decline does. + prompter := &fakePrompter{ + urlCapable: true, + onURL: func(_ context.Context, _ Prompt) error { + return ErrPromptUnavailable + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := m.Authenticate(ctx, prompter) + require.NoError(t, err, "an undeliverable prompt must not abort the flow") + require.NotNil(t, out) + require.NotNil(t, out.UserAction, "an undeliverable prompt must fall back to a user action") + assert.NotEmpty(t, out.UserAction.URL) + assert.Contains(t, out.UserAction.Message, securityAdvisory) + + // A concurrent retry while awaiting the user returns the same fallback action. + out2, err := m.Authenticate(ctx, nil) + require.NoError(t, err) + require.NotNil(t, out2.UserAction) + assert.Equal(t, out.UserAction.URL, out2.UserAction.URL) + + // The background flow stayed alive: opening the URL out of band completes it. + require.NoError(t, browserGet(out.UserAction.URL)) + assert.Equal(t, "gho_access", waitForToken(t, m)) +} + func TestAuthenticateLastDitchUserAction(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) diff --git a/internal/oauth/prompter.go b/internal/oauth/prompter.go index 922558936..1d6aa6ec6 100644 --- a/internal/oauth/prompter.go +++ b/internal/oauth/prompter.go @@ -5,10 +5,18 @@ import ( "errors" ) -// ErrPromptDeclined is returned by a Prompter when the user cancels or declines -// the authorization prompt. +// ErrPromptDeclined is returned by a Prompter when the user actively cancels or +// declines the authorization prompt. It is a deliberate "no", so the flow stops +// rather than falling back to another channel. var ErrPromptDeclined = errors.New("authorization declined by user") +// ErrPromptUnavailable is returned by a Prompter when the prompt could not be +// delivered at all — for example the client advertised an elicitation capability +// but the request failed at the transport or protocol level. Unlike +// ErrPromptDeclined it reflects no user decision, so the flow falls back to a +// channel that needs no client capability instead of giving up. +var ErrPromptUnavailable = errors.New("authorization prompt could not be delivered") + // Prompt is the content shown to the user when asking them to authorize. type Prompt struct { // Message is a human-readable instruction. @@ -36,7 +44,8 @@ type Prompter interface { // until the user acknowledges, declines, or ctx is done. Returning nil means // the prompt was shown (not that authorization completed); the caller waits // for the OAuth flow itself to finish. It returns ErrPromptDeclined if the - // user declines or cancels. + // user declines or cancels, or ErrPromptUnavailable if the prompt could not + // be delivered. PromptURL(ctx context.Context, p Prompt) error // CanPromptForm reports whether the client supports form elicitation, used @@ -44,7 +53,8 @@ type Prompter interface { CanPromptForm() bool // PromptForm presents a textual acknowledgement prompt and blocks until the - // user responds. It returns ErrPromptDeclined if the user declines. + // user responds. It returns ErrPromptDeclined if the user declines, or + // ErrPromptUnavailable if the prompt could not be delivered. PromptForm(ctx context.Context, p Prompt) error } From 5d9a996dba7202ae5b554e96b19febbf822bb57d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 26 Jun 2026 11:58:27 +0200 Subject: [PATCH 5/5] build(oauth): bake in default OAuth credentials for official releases (3/4) (#2711) * build(oauth): bake in default OAuth credentials via build-time ldflags Inject the public OAuth client credentials (stored as the OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET repo secrets) at build time via -ldflags so official binaries and images ship a working default app for zero-config login. Security relies on PKCE, not on the secret. Local/dev builds leave the values empty and continue to require an explicit token or --oauth-client-id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(oauth): recognize github.com host aliases for the baked-in client Match the default host via oauth.NormalizeHost instead of only an empty host string, so an explicit GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps zero-config baked-in login working. GHES and ghe.com users continue to bring their own --oauth-client-id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(oauth): document stdio OAuth login; make PAT optional in install config (#2717) Add a dedicated Local Server OAuth Login guide (docs/oauth-login.md) covering the PKCE/device flows, display channels and the URL-elicitation security advisory, scope-based tool filtering, the fixed-port Docker recipe and its loopback/port-safety behavior, bringing your own OAuth or GitHub App, and the GitHub Enterprise Server / ghe.com requirement to register an app on that host (custom --gh-host directs login at that instance's authorization server). Reflect that the local server now logs in with OAuth by default on github.com: - README: make the stdio Docker install badges OAuth-first (fixed callback port 8085 published to loopback), drop the PAT prompt, and reframe the PAT as an optional alternative with a pointer to the new guide. - server.json: make GITHUB_PERSONAL_ACCESS_TOKEN optional and publish the OAuth callback port so the registry default works without a token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 3 + .github/workflows/goreleaser.yml | 2 + .goreleaser.yaml | 2 +- Dockerfile | 7 +- README.md | 9 +- cmd/github-mcp-server/main.go | 15 +- docs/oauth-login.md | 263 +++++++++++++++++++++++++++ internal/buildinfo/buildinfo.go | 19 ++ server.json | 18 +- 9 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 docs/oauth-login.md create mode 100644 internal/buildinfo/buildinfo.go diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4f452aac4..ac667558f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -117,6 +117,9 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | VERSION=${{ github.ref_name }} + secrets: | + oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }} + oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }} # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 1004fc274..32a6bffad 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -38,6 +38,8 @@ jobs: workdir: . env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} + OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Generate signed build provenance attestations for workflow artifacts uses: actions/attest-build-provenance@v4 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 54f6b9f40..36dfc47bc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index a4ea1d03b..4138e6bcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,14 @@ COPY . . COPY --from=ui-build /app/pkg/github/ui_dist/* ./pkg/github/ui_dist/ # Build the server +# OAuth credentials are injected via build secrets so they are not baked into image history; the values are public in practice but kept out of layers. RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --mount=type=secret,id=oauth_client_id \ + --mount=type=secret,id=oauth_client_secret \ + export OAUTH_CLIENT_ID="$(cat /run/secrets/oauth_client_id 2>/dev/null || echo '')" && \ + export OAUTH_CLIENT_SECRET="$(cat /run/secrets/oauth_client_secret 2>/dev/null || echo '')" && \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app diff --git a/README.md b/README.md index 5d6caae6d..0e44934ca 100644 --- a/README.md +++ b/README.md @@ -176,14 +176,15 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ## Local GitHub MCP Server -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) [![Install with Docker in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D) +[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-p%22%2C%22127.0.0.1%3A8085%3A8085%22%2C%22-e%22%2C%22GITHUB_OAUTH_CALLBACK_PORT%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_OAUTH_CALLBACK_PORT%22%3A%228085%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-p%22%2C%22127.0.0.1%3A8085%3A8085%22%2C%22-e%22%2C%22GITHUB_OAUTH_CALLBACK_PORT%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_OAUTH_CALLBACK_PORT%22%3A%228085%22%7D%7D&quality=insiders) [![Install with Docker in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-p%22%2C%22127.0.0.1%3A8085%3A8085%22%2C%22-e%22%2C%22GITHUB_OAUTH_CALLBACK_PORT%3D8085%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D) ### Prerequisites 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. -3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). +3. **Authentication.** On github.com you don't need to create anything up front — the one-click buttons above log you in with OAuth on first use (a browser-based flow; the token is kept in memory only). The Docker buttons publish a fixed callback port (`127.0.0.1:8085`) so the container's login callback is reachable. See **[Local Server OAuth Login](docs/oauth-login.md)** for how it works, headless/device-code fallback, and bringing your own OAuth or GitHub App (required for GitHub Enterprise Server and `ghe.com`). + + Prefer a token? You can still authenticate with a [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) by setting `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)).
Handling PATs Securely @@ -281,6 +282,8 @@ Install in GitHub Copilot on other IDEs (JetBrains, Visual Studio, Eclipse, etc. Add the following JSON block to your IDE's MCP settings. +> The examples below authenticate with a Personal Access Token. To log in with OAuth instead (no token to create or store), see **[Local Server OAuth Login](docs/oauth-login.md)** — in Docker it needs a fixed callback port, as the one-click buttons above show. + ```json { "mcp": { diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index b329b5012..231b0cf2c 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/github/github-mcp-server/internal/buildinfo" "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" @@ -37,6 +38,18 @@ var ( RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") oauthClientID := viper.GetString("oauth-client-id") + oauthClientSecret := viper.GetString("oauth-client-secret") + // Fall back to the build-time baked-in client (official releases) when none is + // configured explicitly. The baked-in app is registered on github.com, so it is + // only applied to the default host; GHES/ghe.com users must bring their own + // --oauth-client-id. Recognizing the host via NormalizeHost means an explicit + // GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps + // zero-config login working. The secret tracks the id, so an explicitly provided + // id with no secret never picks up the baked-in secret. + if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { + oauthClientID = buildinfo.OAuthClientID + oauthClientSecret = buildinfo.OAuthClientSecret + } if token == "" && oauthClientID == "" { return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") } @@ -112,7 +125,7 @@ var ( } oauthConfig := oauth.NewGitHubConfig( oauthClientID, - viper.GetString("oauth-client-secret"), + oauthClientSecret, scopes, viper.GetString("host"), viper.GetInt("oauth-callback-port"), diff --git a/docs/oauth-login.md b/docs/oauth-login.md new file mode 100644 index 000000000..35989be7b --- /dev/null +++ b/docs/oauth-login.md @@ -0,0 +1,263 @@ +# Local Server OAuth Login (stdio) + +The local (stdio) GitHub MCP Server can log you in with OAuth instead of a +Personal Access Token (PAT). On first use it walks you through GitHub's +authorization flow in your browser and keeps the resulting token **in memory +only** — nothing is written to disk. + +Official released binaries and the `ghcr.io/github/github-mcp-server` image ship +with a registered GitHub OAuth application baked in, so on **github.com** you can +start the server with no token and no client ID at all. To target a different +host (GitHub Enterprise Server or `ghe.com`), or to use your own application, +pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)). + +> OAuth login applies to the **stdio** server only. The remote server and the +> `http` command have their own authentication; see +> [Remote Server](remote-server.md). + +## Contents + +- [How it works](#how-it-works) +- [Quick start](#quick-start) +- [Configuration reference](#configuration-reference) +- [Scope filtering](#scope-filtering) +- [Running in Docker](#running-in-docker) +- [Headless and device-code fallback](#headless-and-device-code-fallback) +- [URL elicitation and the security advisory](#url-elicitation-and-the-security-advisory) +- [Bring your own app](#bring-your-own-app) +- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom) +- [Building from source with baked-in credentials](#building-from-source-with-baked-in-credentials) + +## How it works + +The server prefers the **authorization code flow with PKCE**: it starts a +loopback callback server on your machine, opens GitHub's authorization page, and +exchanges the returned code for a token. PKCE means the client secret is not +required to complete the exchange, which is why a public, distributed client can +ship without a confidential secret. + +To present the authorization URL, the server uses the most secure channel your +MCP client offers, in order: + +1. **Open your browser automatically** (native runs). +2. **URL elicitation** — the client prompts you with the link out of band, so the + URL never enters the model's context. Requires a client that supports MCP + elicitation (e.g. VS Code 1.101+). +3. **A message in the first tool response** — a last resort for clients without + elicitation. This includes a [security advisory](#url-elicitation-and-the-security-advisory). + +If the authorization-code flow can't be used — for example, a container with no +published callback port — the server falls back to the +[device-code flow](#headless-and-device-code-fallback). + +GitHub App tokens that expire are refreshed transparently using the refresh +token, so long-running sessions keep working without re-authorizing. + +## Quick start + +**Native binary (recommended).** Best experience: a random loopback port is +used and your browser opens automatically. On github.com with an official build, +no flags are needed: + +```bash +github-mcp-server stdio +``` + +With your own application: + +```bash +github-mcp-server stdio --oauth-client-id +``` + +VS Code (`.vscode/mcp.json`), using your own app: + +```json +{ + "servers": { + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio", "--oauth-client-id", ""] + } + } +} +``` + +For Docker, see [Running in Docker](#running-in-docker) — containers need a fixed +callback port. + +## Configuration reference + +OAuth login is configured with these stdio flags (each has an environment +variable equivalent). Flags apply only to the `stdio` command. + +| Flag | Environment variable | Description | +|------|----------------------|-------------| +| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | OAuth App or GitHub App client ID. Enables OAuth login when no token is set. Defaults to the baked-in app on github.com for official builds. | +| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Client secret, **if your app requires one**. For distributed clients this is a public, non-confidential credential. | +| `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Comma-separated scopes to request. Also [filters tools](#scope-filtering) to those scopes. Defaults to the full supported set. | +| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed local port for the callback server. Defaults to a random port; set a fixed port when mapping it through Docker. | + +A static token still takes precedence: if `GITHUB_PERSONAL_ACCESS_TOKEN` is set, +the server uses it and skips OAuth entirely. + +## Scope filtering + +The scopes you request determine which tools are exposed. Requesting the full +supported set (the default) hides no tools. Narrowing `--oauth-scopes` both +narrows the token's grant **and** filters out tools that would need a scope you +didn't request, so the tool list reflects what the token can actually do. + +For example, requesting only `repo,read:org` hides tools that require `gist`, +`workflow`, `notifications`, and so on. + +## Running in Docker + +A container can't reach a random loopback port on your host, so Docker OAuth +needs a **fixed** callback port that you publish into the container. Use port +**8085** to match the official app's registered callback URL. + +```bash +docker run -i --rm \ + -p 127.0.0.1:8085:8085 \ + -e GITHUB_OAUTH_CALLBACK_PORT=8085 \ + ghcr.io/github/github-mcp-server +``` + +VS Code (`.vscode/mcp.json`): + +```json +{ + "servers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-p", "127.0.0.1:8085:8085", + "-e", "GITHUB_OAUTH_CALLBACK_PORT", + "ghcr.io/github/github-mcp-server" + ], + "env": { "GITHUB_OAUTH_CALLBACK_PORT": "8085" } + } + } +} +``` + +Because the container can't open your host browser, the authorization URL +arrives via [URL elicitation](#url-elicitation-and-the-security-advisory) or the +tool-response message. After you authorize, your browser hits +`localhost:8085`, which Docker forwards into the container's callback. + +If you bring your own app for Docker, register its callback URL as exactly +`http://localhost:8085/callback`. + +> **Two safety properties to be aware of with a fixed port:** +> +> - **Publish to loopback only** (`-p 127.0.0.1:8085:8085`, not `-p 8085:8085`). +> Inside a container the callback necessarily listens on all interfaces, so a +> plain publish would expose the authorization code to your network. The +> server logs a warning reminding you of this when it binds inside a container. +> - **A busy port is fatal, by design.** With a fixed port, if the server can't +> bind it (another process already holds it), it **stops with an error** rather +> than silently falling back to the device flow. A port you didn't get could +> belong to another user's process positioned to receive the redirect, so the +> server refuses to continue. Free the port or choose a different +> `--oauth-callback-port`. + +## Headless and device-code fallback + +When there's no usable browser or callback — a remote shell, CI, or a container +started without a published port — the server uses GitHub's **device-code +flow**. You'll get a short code and a verification URL to open on any device: + +``` +Visit https://github.com/login/device and enter the code WDJB-MJHT to authorize +the GitHub MCP Server. +``` + +The server polls GitHub until you finish authorizing, then continues. No +callback port is involved, so this works anywhere. + +## URL elicitation and the security advisory + +URL elicitation lets your MCP client present the authorization URL to you +directly, keeping it **out of the model's context** — the model never sees the +link or any code embedded in it. This is the most secure way to hand off the +authorization step. + +If your client doesn't support elicitation, the server falls back to placing the +URL in a tool response and appends a short advisory: + +> Note: your MCP client does not appear to support secure URL elicitation. For +> improved security, consider asking your agent, CLI, or IDE to add it (for +> example, by opening an issue). + +If you see this, your authorization still works — but consider asking your client +vendor to add elicitation support. + +## Bring your own app + +You need your own application when targeting a non-github.com host, or when you'd +rather not use the baked-in app. Either application type works: + +- **[Create an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)** — + simplest to set up. Grants the scopes you request. +- **[Register a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app)** — + finer-grained, per-resource permissions and short-lived tokens that refresh + automatically. Enable **Device Flow** in the app settings if you want the + [headless fallback](#headless-and-device-code-fallback). + +When registering, set the authorization callback URL: + +- **Native runs** use a random loopback port. For loopback redirects GitHub does + not require the callback port to match, so registering + `http://localhost/callback` is sufficient. +- **Docker / fixed port** must match exactly: register + `http://localhost:8085/callback` (or whichever port you publish). + +Then pass the client ID (and secret, only if your app requires one): + +```bash +github-mcp-server stdio \ + --oauth-client-id \ + --oauth-client-secret +``` + +## GitHub Enterprise Server and ghe.com + +The baked-in app is registered on github.com only, so it is **not** used when you +set a custom host. GitHub Enterprise Server and `ghe.com` (Enterprise Cloud with +data residency) users must **bring their own app** registered on that host and +pass `--oauth-client-id`. + +Set the host with `--gh-host` / `GITHUB_HOST`; the server derives the OAuth +authorization, token, and device endpoints from it, so login is directed at your +instance's authorization server rather than github.com: + +```bash +github-mcp-server stdio \ + --gh-host https://github.example.com \ + --oauth-client-id +``` + +- For GitHub Enterprise Server, prefix the host with `https://`. +- For `ghe.com`, use `https://YOURSUBDOMAIN.ghe.com`. + +Register the app's callback URL on the same host (e.g. +`http://localhost/callback` for native runs, or `http://localhost:8085/callback` +for Docker). + +## Building from source with baked-in credentials + +Official builds embed the default OAuth client via linker flags at build time, so +they are not present in the source tree. To produce your own build with embedded +credentials, set them with `-ldflags`: + +```bash +go build -ldflags "\ + -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID= \ + -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=" \ + ./cmd/github-mcp-server +``` + +Without these, a source build simply has no baked-in app and expects +`--oauth-client-id` (or a PAT) at runtime. diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 000000000..cd5084fa2 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,19 @@ +// Package buildinfo contains variables that are set at build time via ldflags. +// These allow official releases to ship default OAuth credentials so users can +// log in without configuring their own OAuth app. The values are public in +// practice (security relies on PKCE, not on the client secret), but are kept out +// of source and injected at build time. +// +// Example: +// +// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx" +package buildinfo + +// OAuthClientID is the default OAuth client ID, set at build time. Empty in +// local/dev builds. +var OAuthClientID string + +// OAuthClientSecret is the default OAuth client secret, set at build time. For +// public OAuth clients it is not truly secret per OAuth 2.1 — PKCE provides the +// security — but it is still injected at build time rather than committed. +var OAuthClientSecret string diff --git a/server.json b/server.json index 15fdf47bd..c77e98ef5 100644 --- a/server.json +++ b/server.json @@ -16,15 +16,27 @@ "type": "stdio" }, "runtimeArguments": [ + { + "type": "named", + "name": "-p", + "description": "Publish the OAuth callback port to loopback so the in-container login callback is reachable", + "value": "127.0.0.1:8085:8085" + }, + { + "type": "named", + "name": "-e", + "description": "Fixed OAuth callback port, matching the published port above", + "value": "GITHUB_OAUTH_CALLBACK_PORT=8085" + }, { "type": "named", "name": "-e", - "description": "Set an environment variable in the runtime", + "description": "Optional GitHub Personal Access Token. Omit to log in with OAuth on first use.", "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", - "isRequired": true, + "isRequired": false, "variables": { "token": { - "isRequired": true, + "isRequired": false, "isSecret": true, "format": "string" }