Skip to content

Commit 4f57b46

Browse files
build(deps): bump go-sdk to 1.7.0-pre.2 and migrate OAuth to multi-round-trip elicitation
The go-sdk 1.7.0-pre.2 bump defaults to MCP protocol 2026-07-28, which per SEP-2322 forbids the server from initiating JSON-RPC requests (including `elicitation/create`) while serving a request. The OAuth login flow presents the authorization prompt via `ServerSession.Elicit`, so on 2026-07-28 sessions it now errors ("cannot be sent while serving a request ... return an InputRequests map instead"), which broke TestSessionPrompterPromptActions and would break real 2026-07-28 clients (stdio included, since server/discover is transport-agnostic). Migrate the OAuth middleware to multi-round-trip requests (MRTR) while keeping pre-2026-07-28 clients unchanged: - Legacy clients (< 2026-07-28) keep presenting the prompt via server-initiated elicitation exactly as before. - Modern clients (>= 2026-07-28) receive the authorization prompt as an `InputRequests` elicitation returned from the tool call; the client fulfills it and retries, and the middleware then awaits the token and proceeds. This keeps the authorization URL out of the model context. oauth.Manager gains AwaitToken (resume half of MRTR) and Cancel (tear down on decline). Tests cover the accept/decline/no-capability MRTR paths and assert that server-initiated elicitation is reported undeliverable on 2026-07-28. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 18e70efa-1b2d-4290-ba51-b82998db4ff8
1 parent c36e4e4 commit 4f57b46

8 files changed

Lines changed: 374 additions & 76 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ require (
1010
github.com/josephburnett/jd/v2 v2.5.0
1111
github.com/lithammer/fuzzysearch v1.1.8
1212
github.com/microcosm-cc/bluemonday v1.0.27
13-
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1
13+
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2
1414
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021
1515
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
1616
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
3939
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
4040
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
4141
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
42-
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 h1:GlMIJyMHFX76bBSQuBCLXZ7pB9cGh4VBS6O5wGd0tgI=
43-
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
42+
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2 h1:3JwUps1pdSpXYndBMGO9SMca6CSkP9AKnOaKAkSSGHc=
43+
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
4444
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g=
4545
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc=
4646
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=

internal/ghmcp/oauth.go

Lines changed: 141 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,47 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e
9191
type oauthAuthenticator interface {
9292
HasToken() bool
9393
Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error)
94+
AwaitToken(ctx context.Context) (*oauth.Outcome, error)
95+
Cancel()
96+
}
97+
98+
// oauthElicitID is the stable key for the authorization elicitation in the
99+
// multi-round-trip flow. The client echoes it back in InputResponses when it
100+
// retries the tool call, so the middleware can recognize the user's response.
101+
const oauthElicitID = "github_authorization"
102+
103+
// protocolVersionNoServerElicitation is the first MCP protocol version that
104+
// forbids server-initiated JSON-RPC requests (SEP-2322): from this version on
105+
// the server may not send elicitation/create while serving a request and must
106+
// instead return an InputRequests map from the tool call (multi round-trip
107+
// requests). It mirrors the go-sdk's internal constant of the same value, which
108+
// the SDK does not export.
109+
const protocolVersionNoServerElicitation = "2026-07-28"
110+
111+
// serverMayInitiateElicitation reports whether the server is permitted to send
112+
// elicitation requests to the client itself, which the spec allows only before
113+
// protocol version 2026-07-28. A nil or un-negotiated session (only reached in
114+
// unit tests; a real tools/call is always initialized) is treated as legacy.
115+
func serverMayInitiateElicitation(ss *mcp.ServerSession) bool {
116+
if ss == nil {
117+
return true
118+
}
119+
params := ss.InitializeParams()
120+
return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation
94121
}
95122

96123
// createOAuthMiddleware returns receiving middleware that authorizes the session
97124
// lazily, on the first tool call. Authorization is deferred until here (rather
98125
// than at startup) because the prompts depend on an initialized session whose
99-
// elicitation capabilities are known.
126+
// elicitation capabilities and protocol version are known.
100127
//
101128
// When a token is already available the call proceeds untouched. Otherwise the
102-
// flow runs: secure channels (browser, URL elicitation) block until the token
103-
// arrives and then the call proceeds; the last-resort channel returns the
104-
// instruction to the user as a tool result and asks them to retry.
129+
// authorization flow runs, presenting its prompt over whichever channel the
130+
// negotiated protocol allows: on protocol versions before 2026-07-28 the server
131+
// elicits directly; from 2026-07-28 on, where server-initiated requests are
132+
// forbidden (SEP-2322), it uses multi-round-trip elicitation returned from the
133+
// tool call. Either way the last-resort channel returns the instruction as a
134+
// tool result and asks the user to retry.
105135
func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler {
106136
return func(next mcp.MethodHandler) mcp.MethodHandler {
107137
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
@@ -114,18 +144,115 @@ func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(nex
114144
return next(ctx, method, request)
115145
}
116146

117-
outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session})
118-
if err != nil {
119-
return nil, fmt.Errorf("github authorization failed: %w", err)
120-
}
121-
if outcome != nil && outcome.UserAction != nil {
122-
logger.Info("surfacing github authorization instructions to user")
123-
return &mcp.CallToolResult{
124-
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
125-
}, nil
147+
if serverMayInitiateElicitation(callReq.Session) {
148+
return authorizeViaServerElicitation(ctx, mgr, next, method, request, callReq, logger)
126149
}
127-
return next(ctx, method, request)
150+
return authorizeViaMultiRoundTrip(ctx, mgr, next, method, request, callReq, logger)
151+
}
152+
}
153+
}
154+
155+
// authorizeViaServerElicitation drives authorization on legacy protocol versions
156+
// (before 2026-07-28), where the server may present the prompt itself via
157+
// server-initiated elicitation. It blocks until the token arrives, then proceeds.
158+
func authorizeViaServerElicitation(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) {
159+
outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session})
160+
if err != nil {
161+
return nil, fmt.Errorf("github authorization failed: %w", err)
162+
}
163+
if outcome != nil && outcome.UserAction != nil {
164+
logger.Info("surfacing github authorization instructions to user")
165+
return &mcp.CallToolResult{
166+
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
167+
}, nil
168+
}
169+
return next(ctx, method, request)
170+
}
171+
172+
// authorizeViaMultiRoundTrip drives authorization on protocol version 2026-07-28
173+
// and later, where server-initiated requests are forbidden (SEP-2322). The first
174+
// tool call starts the flow and returns the authorization prompt as an
175+
// elicitation input request; the client presents it and retries the call with
176+
// the user's response, at which point we wait for the token and proceed.
177+
func authorizeViaMultiRoundTrip(ctx context.Context, mgr oauthAuthenticator, next mcp.MethodHandler, method string, request mcp.Request, callReq *mcp.CallToolRequest, logger *slog.Logger) (mcp.Result, error) {
178+
// Retry: the client fulfilled the authorization elicitation and re-sent the
179+
// call with the user's response.
180+
if resp, ok := callReq.Params.InputResponses[oauthElicitID]; ok {
181+
res, _ := resp.(*mcp.ElicitResult)
182+
if res == nil || res.Action != "accept" {
183+
// The user declined or dismissed the prompt; tear the flow down so it
184+
// does not linger, and let them retry when they are ready.
185+
mgr.Cancel()
186+
return &mcp.CallToolResult{
187+
Content: []mcp.Content{&mcp.TextContent{Text: "GitHub authorization was declined. Retry when you're ready to authorize."}},
188+
}, nil
128189
}
190+
outcome, err := mgr.AwaitToken(ctx)
191+
if err != nil {
192+
return nil, fmt.Errorf("github authorization failed: %w", err)
193+
}
194+
if outcome != nil && outcome.UserAction != nil {
195+
// The user acknowledged the prompt but has not finished authorizing;
196+
// surface the instructions so they can complete it and retry.
197+
logger.Info("surfacing github authorization instructions to user")
198+
return &mcp.CallToolResult{
199+
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
200+
}, nil
201+
}
202+
return next(ctx, method, request)
203+
}
204+
205+
// First attempt: start the flow. A nil prompter keeps the manager from
206+
// initiating any elicitation itself (forbidden on this protocol); it opens a
207+
// server-side browser when possible, otherwise returns the authorization
208+
// instructions for us to present via multi-round-trip elicitation.
209+
outcome, err := mgr.Authenticate(ctx, nil)
210+
if err != nil {
211+
return nil, fmt.Errorf("github authorization failed: %w", err)
212+
}
213+
if outcome == nil || outcome.UserAction == nil {
214+
// Already authorized (e.g. the server opened a browser and the flow
215+
// completed); proceed.
216+
return next(ctx, method, request)
217+
}
218+
219+
elicit := authorizationElicitParams(outcome.UserAction, &sessionPrompter{session: callReq.Session})
220+
if elicit == nil {
221+
// The client cannot present an elicitation (no capability, or no URL to
222+
// show); fall back to returning the instructions as a tool result.
223+
logger.Info("surfacing github authorization instructions to user")
224+
return &mcp.CallToolResult{
225+
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
226+
}, nil
227+
}
228+
logger.Info("requesting github authorization via elicitation")
229+
return &mcp.CallToolResult{
230+
InputRequests: mcp.InputRequestMap{oauthElicitID: elicit},
231+
RequestState: "github-authorization-pending",
232+
}, nil
233+
}
234+
235+
// authorizationElicitParams builds the elicitation that presents the
236+
// authorization instructions to the user. It mirrors sessionPrompter's channel
237+
// selection: URL-mode when the client supports it, otherwise form-mode. It
238+
// returns nil when the client advertised no elicitation capability or there is
239+
// no authorization URL to show, so the caller falls back to a tool-result
240+
// message.
241+
func authorizationElicitParams(ua *oauth.UserAction, p *sessionPrompter) *mcp.ElicitParams {
242+
if ua.URL == "" {
243+
return nil
244+
}
245+
message := "Authorize the GitHub MCP Server to continue."
246+
if ua.UserCode != "" {
247+
message = fmt.Sprintf("Enter code %s to authorize the GitHub MCP Server.", ua.UserCode)
248+
}
249+
switch {
250+
case p.CanPromptURL():
251+
return &mcp.ElicitParams{Mode: "url", Message: message, URL: ua.URL, ElicitationID: rand.Text()}
252+
case p.CanPromptForm():
253+
return &mcp.ElicitParams{Mode: "form", Message: message}
254+
default:
255+
return nil
129256
}
130257
}
131258

0 commit comments

Comments
 (0)