Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
201 changes: 175 additions & 26 deletions internal/ghmcp/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package ghmcp
import (
"context"
"crypto/rand"
"errors"
"fmt"
"log/slog"
"strings"

"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

Expand Down Expand Up @@ -91,41 +94,187 @@ 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, flowID string) (*oauth.Outcome, error)
Cancel(flowID string) bool
}

// 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.
// oauthElicitIDPrefix identifies authorization responses in the multi-round-trip
// InputResponses map. The suffix is the manager's per-flow ID, which prevents a
// delayed response from an older prompt from affecting a newer flow.
const oauthElicitIDPrefix = "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
}

// createOAuthToolMiddleware returns tool-handler middleware that authorizes the
// session lazily, on the first tool call. It runs inside the SDK's
// Server.callTool handler so results returned here still receive SDK
// finalization, including resultType: "input_required" for multi-round-trip
// responses.
//
// 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)
// 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 createOAuthToolMiddleware(mgr oauthAuthenticator, logger *slog.Logger) inventory.ToolHandlerMiddleware {
return func(next mcp.ToolHandler) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if !serverMayInitiateElicitation(req.Session) {
if flowID, response, ok := authorizationElicitResponse(req.Params.InputResponses); ok {
return resumeMultiRoundTripAuthorization(ctx, mgr, next, req, flowID, response, logger)
}
}

callReq, ok := request.(*mcp.CallToolRequest)
if !ok {
return next(ctx, method, request)
if mgr.HasToken() {
return next(ctx, req)
}

outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session})
if err != nil {
return nil, fmt.Errorf("github authorization failed: %w", err)
if serverMayInitiateElicitation(req.Session) {
return authorizeViaServerElicitation(ctx, mgr, next, req, logger)
}
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)
return startMultiRoundTripAuthorization(ctx, mgr, next, req, 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.ToolHandler, req *mcp.CallToolRequest, logger *slog.Logger) (*mcp.CallToolResult, error) {
outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: req.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, req)
}

// authorizationElicitResponse finds the authorization response and extracts the
// flow ID encoded in its input-request key.
func authorizationElicitResponse(responses mcp.InputResponseMap) (string, *mcp.ElicitResult, bool) {
for id, response := range responses {
flowID, ok := strings.CutPrefix(id, oauthElicitIDPrefix)
if !ok || flowID == "" {
continue
}
result, _ := response.(*mcp.ElicitResult)
return flowID, result, true
}
return "", nil, false
}

// startMultiRoundTripAuthorization starts authorization on protocol version
// 2026-07-28 or later. Server-initiated requests are forbidden there (SEP-2322),
// so the prompt is returned as an elicitation input request for the client to
// fulfill and retry.
func startMultiRoundTripAuthorization(ctx context.Context, mgr oauthAuthenticator, next mcp.ToolHandler, req *mcp.CallToolRequest, logger *slog.Logger) (*mcp.CallToolResult, error) {
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, req)
}

elicit := authorizationElicitParams(outcome.UserAction, &sessionPrompter{session: req.Session})
if elicit == nil || outcome.FlowID == "" {
// The client cannot present an elicitation (no capability, or no URL to
// show), or the flow cannot be correlated; 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{oauthElicitIDPrefix + outcome.FlowID: elicit},
}, nil
Comment thread
SamMorrowDrums marked this conversation as resolved.
}

// resumeMultiRoundTripAuthorization handles the client's retry after it
// fulfilled the authorization elicitation.
func resumeMultiRoundTripAuthorization(ctx context.Context, mgr oauthAuthenticator, next mcp.ToolHandler, req *mcp.CallToolRequest, flowID string, response *mcp.ElicitResult, logger *slog.Logger) (*mcp.CallToolResult, error) {
if response == nil || response.Action != "accept" {
if !mgr.Cancel(flowID) {
return expiredAuthorizationResult(), nil
}
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, flowID)
if errors.Is(err, oauth.ErrStaleAuthorizationFlow) {
return expiredAuthorizationResult(), nil
}
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, req)
}

func expiredAuthorizationResult() *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "This GitHub authorization prompt has expired. Retry the request to authorize again."}},
}
}

// 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: ua.Message}
default:
return nil
}
}

Expand Down
Loading
Loading