Skip to content

Commit 42015ed

Browse files
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>
1 parent f707dba commit 42015ed

7 files changed

Lines changed: 736 additions & 15 deletions

File tree

cmd/github-mcp-server/main.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"time"
99

1010
"github.com/github/github-mcp-server/internal/ghmcp"
11+
"github.com/github/github-mcp-server/internal/oauth"
1112
"github.com/github/github-mcp-server/pkg/github"
1213
ghhttp "github.com/github/github-mcp-server/pkg/http"
14+
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
1315
"github.com/spf13/cobra"
1416
"github.com/spf13/pflag"
1517
"github.com/spf13/viper"
@@ -34,8 +36,9 @@ var (
3436
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3537
RunE: func(_ *cobra.Command, _ []string) error {
3638
token := viper.GetString("personal_access_token")
37-
if token == "" {
38-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
39+
oauthClientID := viper.GetString("oauth-client-id")
40+
if token == "" && oauthClientID == "" {
41+
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth")
3942
}
4043

4144
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -95,6 +98,29 @@ var (
9598
ExcludeTools: excludeTools,
9699
RepoAccessCacheTTL: &ttl,
97100
}
101+
102+
// When no static token is provided, log in via OAuth using the given
103+
// client. The requested scopes default to the full supported set
104+
// (which filters out no tools); an explicit, narrower --oauth-scopes
105+
// both narrows the grant and hides tools needing other scopes.
106+
if token == "" {
107+
scopes := ghoauth.SupportedScopes
108+
if viper.IsSet("oauth-scopes") {
109+
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
110+
return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err)
111+
}
112+
}
113+
oauthConfig := oauth.NewGitHubConfig(
114+
oauthClientID,
115+
viper.GetString("oauth-client-secret"),
116+
scopes,
117+
viper.GetString("host"),
118+
viper.GetInt("oauth-callback-port"),
119+
)
120+
stdioServerConfig.OAuthManager = oauth.NewManager(oauthConfig, nil)
121+
stdioServerConfig.OAuthScopes = scopes
122+
}
123+
98124
return ghmcp.RunStdioServer(stdioServerConfig)
99125
},
100126
}
@@ -183,6 +209,14 @@ func init() {
183209
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
184210
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
185211

212+
// stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token)
213+
// to log in via the browser-based OAuth flow on first use. Works for both
214+
// OAuth Apps and GitHub Apps.
215+
stdioCmd.Flags().String("oauth-client-id", "", "OAuth App or GitHub App client ID, enabling interactive OAuth login when no token is set")
216+
stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret, if the app requires one (it is a public, non-confidential credential for distributed clients)")
217+
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set")
218+
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")
219+
186220
// HTTP-specific flags
187221
httpCmd.Flags().Int("port", 8082, "HTTP server port")
188222
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() {
205239
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
206240
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
207241
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
242+
_ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id"))
243+
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
244+
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))
245+
_ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port"))
208246
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
209247
_ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))
210248
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))

internal/ghmcp/oauth.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package ghmcp
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"fmt"
7+
"log/slog"
8+
9+
"github.com/github/github-mcp-server/internal/oauth"
10+
"github.com/modelcontextprotocol/go-sdk/mcp"
11+
)
12+
13+
// sessionPrompter adapts an MCP server session to oauth.Prompter, presenting
14+
// authorization prompts to the user via elicitation. Keeping the prompt on the
15+
// MCP control channel (rather than a tool result) keeps the authorization URL
16+
// and any session-bound state out of the model's context.
17+
type sessionPrompter struct {
18+
session *mcp.ServerSession
19+
}
20+
21+
// elicitationCaps returns the client's declared elicitation capabilities, or nil
22+
// if the client did not advertise any.
23+
func (p *sessionPrompter) elicitationCaps() *mcp.ElicitationCapabilities {
24+
params := p.session.InitializeParams()
25+
if params == nil || params.Capabilities == nil {
26+
return nil
27+
}
28+
return params.Capabilities.Elicitation
29+
}
30+
31+
// CanPromptURL reports whether the client supports URL-mode elicitation.
32+
func (p *sessionPrompter) CanPromptURL() bool {
33+
caps := p.elicitationCaps()
34+
return caps != nil && caps.URL != nil
35+
}
36+
37+
// PromptURL presents the authorization URL via URL-mode elicitation and blocks
38+
// until the user acknowledges, declines, or ctx is done.
39+
func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) error {
40+
res, err := p.session.Elicit(ctx, &mcp.ElicitParams{
41+
Mode: "url",
42+
Message: prompt.Message,
43+
URL: prompt.URL,
44+
ElicitationID: rand.Text(),
45+
})
46+
if err != nil {
47+
return err
48+
}
49+
if res.Action != "accept" {
50+
return oauth.ErrPromptDeclined
51+
}
52+
return nil
53+
}
54+
55+
// CanPromptForm reports whether the client supports form-mode elicitation. The
56+
// SDK treats a client that advertises neither form nor URL capabilities as
57+
// supporting forms, for backward compatibility, so we mirror that here.
58+
func (p *sessionPrompter) CanPromptForm() bool {
59+
caps := p.elicitationCaps()
60+
if caps == nil {
61+
return false
62+
}
63+
return caps.Form != nil || caps.URL == nil
64+
}
65+
66+
// PromptForm presents a textual acknowledgement (used to display a device code
67+
// when URL elicitation is unavailable) and blocks until the user responds.
68+
func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) error {
69+
res, err := p.session.Elicit(ctx, &mcp.ElicitParams{
70+
Mode: "form",
71+
Message: prompt.Message,
72+
})
73+
if err != nil {
74+
return err
75+
}
76+
if res.Action != "accept" {
77+
return oauth.ErrPromptDeclined
78+
}
79+
return nil
80+
}
81+
82+
// oauthAuthenticator is the subset of *oauth.Manager that the middleware needs.
83+
// Depending on the interface (rather than the concrete manager) lets the
84+
// middleware be exercised with a deterministic fake, since driving the real
85+
// manager to its branches would require standing up live GitHub flows.
86+
type oauthAuthenticator interface {
87+
HasToken() bool
88+
Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error)
89+
}
90+
91+
// createOAuthMiddleware returns receiving middleware that authorizes the session
92+
// lazily, on the first tool call. Authorization is deferred until here (rather
93+
// than at startup) because the prompts depend on an initialized session whose
94+
// elicitation capabilities are known.
95+
//
96+
// When a token is already available the call proceeds untouched. Otherwise the
97+
// flow runs: secure channels (browser, URL elicitation) block until the token
98+
// arrives and then the call proceeds; the last-resort channel returns the
99+
// instruction to the user as a tool result and asks them to retry.
100+
func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler {
101+
return func(next mcp.MethodHandler) mcp.MethodHandler {
102+
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
103+
if method != "tools/call" || mgr.HasToken() {
104+
return next(ctx, method, request)
105+
}
106+
107+
callReq, ok := request.(*mcp.CallToolRequest)
108+
if !ok {
109+
return next(ctx, method, request)
110+
}
111+
112+
outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session})
113+
if err != nil {
114+
return nil, fmt.Errorf("github authorization failed: %w", err)
115+
}
116+
if outcome != nil && outcome.UserAction != nil {
117+
logger.Info("surfacing github authorization instructions to user")
118+
return &mcp.CallToolResult{
119+
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
120+
}, nil
121+
}
122+
return next(ctx, method, request)
123+
}
124+
}
125+
}
126+
127+
// ensure sessionPrompter satisfies the Prompter contract.
128+
var _ oauth.Prompter = (*sessionPrompter)(nil)

0 commit comments

Comments
 (0)