Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ require (
github.com/josephburnett/jd/v2 v2.5.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/microcosm-cc/bluemonday v1.0.27
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 h1:GlMIJyMHFX76bBSQuBCLXZ7pB9cGh4VBS6O5wGd0tgI=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2 h1:3JwUps1pdSpXYndBMGO9SMca6CSkP9AKnOaKAkSSGHc=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
Expand Down
155 changes: 141 additions & 14 deletions internal/ghmcp/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,47 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e
type oauthAuthenticator interface {
HasToken() bool
Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error)
AwaitToken(ctx context.Context) (*oauth.Outcome, error)
Cancel()
}

// oauthElicitID is the stable key for the authorization elicitation in the
// multi-round-trip flow. The client echoes it back in InputResponses when it
// retries the tool call, so the middleware can recognize the user's response.
const oauthElicitID = "github_authorization"

// protocolVersionNoServerElicitation is the first MCP protocol version that
// forbids server-initiated JSON-RPC requests (SEP-2322): from this version on
// the server may not send elicitation/create while serving a request and must
// instead return an InputRequests map from the tool call (multi round-trip
// requests). It mirrors the go-sdk's internal constant of the same value, which
// the SDK does not export.
const protocolVersionNoServerElicitation = "2026-07-28"

// serverMayInitiateElicitation reports whether the server is permitted to send
// elicitation requests to the client itself, which the spec allows only before
// protocol version 2026-07-28. A nil or un-negotiated session (only reached in
// unit tests; a real tools/call is always initialized) is treated as legacy.
func serverMayInitiateElicitation(ss *mcp.ServerSession) bool {
if ss == nil {
return true
}
params := ss.InitializeParams()
return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation
}

// 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.
// elicitation capabilities and protocol version 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.
// authorization flow runs, presenting its prompt over whichever channel the
// negotiated protocol allows: on protocol versions before 2026-07-28 the server
// elicits directly; from 2026-07-28 on, where server-initiated requests are
// forbidden (SEP-2322), it uses multi-round-trip elicitation returned from the
// tool call. Either way the last-resort channel returns the instruction as a
// tool result and asks the user 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) {
Expand All @@ -114,18 +144,115 @@ func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(nex
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
if serverMayInitiateElicitation(callReq.Session) {
return authorizeViaServerElicitation(ctx, mgr, next, method, request, callReq, logger)
}
return next(ctx, method, request)
return authorizeViaMultiRoundTrip(ctx, mgr, next, method, request, callReq, logger)
}
}
}

// authorizeViaServerElicitation drives authorization on legacy protocol versions
// (before 2026-07-28), where the server may present the prompt itself via
// server-initiated elicitation. It blocks until the token arrives, then proceeds.
func authorizeViaServerElicitation(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) {
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)
}

// authorizeViaMultiRoundTrip drives authorization on protocol version 2026-07-28
// and later, where server-initiated requests are forbidden (SEP-2322). The first
// tool call starts the flow and returns the authorization prompt as an
// elicitation input request; the client presents it and retries the call with
// the user's response, at which point we wait for the token and proceed.
func authorizeViaMultiRoundTrip(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) {
// Retry: the client fulfilled the authorization elicitation and re-sent the
// call with the user's response.
if resp, ok := callReq.Params.InputResponses[oauthElicitID]; ok {
res, _ := resp.(*mcp.ElicitResult)
if res == nil || res.Action != "accept" {
// The user declined or dismissed the prompt; tear the flow down so it
// does not linger, and let them retry when they are ready.
mgr.Cancel()
Comment thread
SamMorrowDrums marked this conversation as resolved.
Resolved
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "GitHub authorization was declined. Retry when you're ready to authorize."}},
}, nil
}
outcome, err := mgr.AwaitToken(ctx)
if err != nil {
return nil, fmt.Errorf("github authorization failed: %w", err)
}
if outcome != nil && outcome.UserAction != nil {
// The user acknowledged the prompt but has not finished authorizing;
// surface the instructions so they can complete it and retry.
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)
}

// First attempt: start the flow. A nil prompter keeps the manager from
// initiating any elicitation itself (forbidden on this protocol); it opens a
// server-side browser when possible, otherwise returns the authorization
// instructions for us to present via multi-round-trip elicitation.
outcome, err := mgr.Authenticate(ctx, nil)
if err != nil {
return nil, fmt.Errorf("github authorization failed: %w", err)
}
if outcome == nil || outcome.UserAction == nil {
// Already authorized (e.g. the server opened a browser and the flow
// completed); proceed.
return next(ctx, method, request)
}

elicit := authorizationElicitParams(outcome.UserAction, &sessionPrompter{session: callReq.Session})
if elicit == nil {
// The client cannot present an elicitation (no capability, or no URL to
// show); fall back to returning the instructions as a tool result.
logger.Info("surfacing github authorization instructions to user")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
}, nil
}
logger.Info("requesting github authorization via elicitation")
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{oauthElicitID: elicit},
RequestState: "github-authorization-pending",
}, nil
Comment thread
SamMorrowDrums marked this conversation as resolved.
Resolved
}

// authorizationElicitParams builds the elicitation that presents the
// authorization instructions to the user. It mirrors sessionPrompter's channel
// selection: URL-mode when the client supports it, otherwise form-mode. It
// returns nil when the client advertised no elicitation capability or there is
// no authorization URL to show, so the caller falls back to a tool-result
// message.
func authorizationElicitParams(ua *oauth.UserAction, p *sessionPrompter) *mcp.ElicitParams {
if ua.URL == "" {
return nil
}
message := "Authorize the GitHub MCP Server to continue."
if ua.UserCode != "" {
message = fmt.Sprintf("Enter code %s to authorize the GitHub MCP Server.", ua.UserCode)
}
switch {
case p.CanPromptURL():
return &mcp.ElicitParams{Mode: "url", Message: message, URL: ua.URL, ElicitationID: rand.Text()}
case p.CanPromptForm():
return &mcp.ElicitParams{Mode: "form", Message: message}
Comment thread
SamMorrowDrums marked this conversation as resolved.
Resolved
default:
return nil
}
}

Expand Down
Loading
Loading