@@ -91,17 +91,47 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e
9191type 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.
105135func 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