Skip to content

Commit b7e81b8

Browse files
Distinguish undeliverable auth prompts from user declines
An elicitation prompt that the client cannot deliver (a transport or protocol failure) was treated the same as a user actively declining: any display error cancelled the flow. That conflated a system failure with a deliberate "no", so a client that advertised URL elicitation but failed to deliver it would hard-fail the login instead of degrading. Add an ErrPromptUnavailable sentinel alongside ErrPromptDeclined and have the MCP adapter return it when Elicit fails at the transport level. The manager now falls back to the manual user-action channel on an undeliverable prompt (keeping the background flow alive so the user can still authorize out of band), while a genuine decline still aborts. A context-cancelled prompt is checked first so an ending flow is never misread as a transport failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2b4d5e6 commit b7e81b8

6 files changed

Lines changed: 185 additions & 30 deletions

File tree

internal/ghmcp/oauth.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) er
4444
ElicitationID: rand.Text(),
4545
})
4646
if err != nil {
47-
return err
47+
// The client advertised URL elicitation but the request itself failed:
48+
// classify it as undeliverable (not a user decision) so the flow can fall
49+
// back to a channel that needs no client capability.
50+
return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err)
4851
}
4952
if res.Action != "accept" {
5053
return oauth.ErrPromptDeclined
@@ -71,7 +74,9 @@ func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) e
7174
Message: prompt.Message,
7275
})
7376
if err != nil {
74-
return err
77+
// As with PromptURL, a delivery failure is undeliverable rather than a
78+
// decline, so the flow can fall back instead of aborting.
79+
return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err)
7580
}
7681
if res.Action != "accept" {
7782
return oauth.ErrPromptDeclined

internal/ghmcp/oauth_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ghmcp
22

33
import (
44
"context"
5+
"errors"
56
"io"
67
"log/slog"
78
"net/http"
@@ -193,6 +194,51 @@ func TestSessionPrompterPromptActions(t *testing.T) {
193194
}
194195
}
195196

197+
// TestSessionPrompterTransportError verifies that a prompt which fails to be
198+
// delivered (the client errors instead of returning an action) is reported as
199+
// ErrPromptUnavailable, not ErrPromptDeclined. The manager relies on this
200+
// distinction to fall back to manual instructions instead of aborting.
201+
func TestSessionPrompterTransportError(t *testing.T) {
202+
t.Parallel()
203+
204+
caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{
205+
URL: &mcp.URLElicitationCapabilities{},
206+
Form: &mcp.FormElicitationCapabilities{},
207+
}}
208+
209+
for _, mode := range []string{"url", "form"} {
210+
t.Run(mode, func(t *testing.T) {
211+
t.Parallel()
212+
213+
handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
214+
return nil, errors.New("client cannot deliver elicitation")
215+
}
216+
217+
got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string {
218+
var err error
219+
if mode == "url" {
220+
err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"})
221+
} else {
222+
err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"})
223+
}
224+
switch {
225+
case err == nil:
226+
return "ok"
227+
case errors.Is(err, oauth.ErrPromptDeclined):
228+
return "declined"
229+
case errors.Is(err, oauth.ErrPromptUnavailable):
230+
return "unavailable"
231+
default:
232+
return "error: " + err.Error()
233+
}
234+
})
235+
236+
assert.Equal(t, "unavailable", got,
237+
"a delivery failure must be classified as undeliverable, not a decline")
238+
})
239+
}
240+
}
241+
196242
// fakeAuthenticator is a deterministic stand-in for *oauth.Manager that lets the
197243
// middleware be tested at each branch without standing up live GitHub flows.
198244
type fakeAuthenticator struct {

internal/oauth/flow.go

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ type flowPlan struct {
2424
// poll the device endpoint) and returns the token.
2525
run func(context.Context) (*oauth2.Token, error)
2626
// display, if set, presents the prompt to the user via the Prompter and
27-
// blocks until they act. A non-nil error (including ErrPromptDeclined)
28-
// aborts the flow.
27+
// blocks until they act. ErrPromptDeclined (the user said no) or any other
28+
// error aborts the flow, except ErrPromptUnavailable, which degrades to
29+
// fallback when that is set.
2930
display func(context.Context) error
31+
// fallback, if set alongside display, is the manual user action to surface
32+
// when the display prompt cannot be delivered (ErrPromptUnavailable). It lets
33+
// a runtime elicitation failure degrade to the manual channel — keeping the
34+
// background flow alive — instead of aborting.
35+
fallback *UserAction
3036
// userAction, if set, indicates the last-resort channel: the caller must
3137
// surface it and the user retries after authorizing out of band.
3238
userAction *UserAction
@@ -124,23 +130,27 @@ func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) {
124130
m.logger.Debug("browser auto-open unavailable", "reason", browserErr)
125131
}
126132

133+
// The manual instructions double as the fallback if a chosen display channel
134+
// turns out to be undeliverable at runtime, so build them once here.
135+
manual := &UserAction{
136+
URL: authURL,
137+
Message: fmt.Sprintf(
138+
"To authorize the GitHub MCP Server, open this URL in your browser:\n\n%s\n\nAfter authorizing, retry your request.\n\n%s",
139+
authURL, securityAdvisory,
140+
),
141+
}
142+
127143
if canPromptURL(prompter) {
128144
display := func(ctx context.Context) error {
129145
return prompter.PromptURL(ctx, Prompt{
130146
Message: "Authorize the GitHub MCP Server in your browser to continue.",
131147
URL: authURL,
132148
})
133149
}
134-
return &flowPlan{run: run, display: display}, nil
150+
return &flowPlan{run: run, display: display, fallback: manual}, nil
135151
}
136152

137-
return &flowPlan{run: run, userAction: &UserAction{
138-
URL: authURL,
139-
Message: fmt.Sprintf(
140-
"To authorize the GitHub MCP Server, open this URL in your browser:\n\n%s\n\nAfter authorizing, retry your request.\n\n%s",
141-
authURL, securityAdvisory,
142-
),
143-
}}, nil
153+
return &flowPlan{run: run, userAction: manual}, nil
144154
}
145155

146156
// beginDevice prepares the device authorization flow. It requests a device code
@@ -164,6 +174,17 @@ func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) {
164174
return tok, nil
165175
}
166176

177+
// As with PKCE, the manual instructions double as the runtime fallback, so
178+
// build them once and reuse for both display plans and the last resort.
179+
manual := &UserAction{
180+
URL: da.VerificationURI,
181+
UserCode: da.UserCode,
182+
Message: fmt.Sprintf(
183+
"%s\n\nAfter authorizing, retry your request.\n\n%s",
184+
deviceInstruction(da), securityAdvisory,
185+
),
186+
}
187+
167188
if canPromptURL(prompter) {
168189
display := func(ctx context.Context) error {
169190
return prompter.PromptURL(ctx, Prompt{
@@ -172,7 +193,7 @@ func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) {
172193
UserCode: da.UserCode,
173194
})
174195
}
175-
return &flowPlan{run: run, display: display}, nil
196+
return &flowPlan{run: run, display: display, fallback: manual}, nil
176197
}
177198

178199
if canPromptForm(prompter) {
@@ -183,17 +204,10 @@ func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) {
183204
UserCode: da.UserCode,
184205
})
185206
}
186-
return &flowPlan{run: run, display: display}, nil
207+
return &flowPlan{run: run, display: display, fallback: manual}, nil
187208
}
188209

189-
return &flowPlan{run: run, userAction: &UserAction{
190-
URL: da.VerificationURI,
191-
UserCode: da.UserCode,
192-
Message: fmt.Sprintf(
193-
"%s\n\nAfter authorizing, retry your request.\n\n%s",
194-
deviceInstruction(da), securityAdvisory,
195-
),
196-
}}, nil
210+
return &flowPlan{run: run, userAction: manual}, nil
197211
}
198212

199213
// securityAdvisory nudges users on clients without URL elicitation to ask their

internal/oauth/manager.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,32 @@ func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome
190190
}
191191

192192
// runFlow executes a prepared flow in the background and records the result. The
193-
// optional display prompt runs concurrently; if it ends in error or decline it
194-
// cancels the flow.
193+
// optional display prompt runs concurrently: a decline (or other failure) aborts
194+
// the flow, while an undeliverable prompt degrades to the manual fallback without
195+
// tearing the flow down, so the user can still authorize out of band.
195196
func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan *flowPlan) {
196197
defer cancel()
197198

198199
if plan.display != nil {
199200
go func() {
200-
if err := plan.display(ctx); err != nil {
201+
err := plan.display(ctx)
202+
switch {
203+
case err == nil:
204+
// Prompt shown; the flow completes when the token arrives.
205+
case ctx.Err() != nil:
206+
// The flow is already ending (timed out or cancelled elsewhere),
207+
// so there is nothing to fall back to. Checking this before the
208+
// fallback also prevents misreading a context-cancelled prompt as
209+
// a transport failure.
210+
case errors.Is(err, ErrPromptUnavailable) && plan.fallback != nil:
211+
// The client advertised the capability but could not deliver the
212+
// prompt. Surface the manual instructions instead of failing, and
213+
// keep the background flow alive so the user can still authorize.
214+
m.logger.Debug("authorization prompt undeliverable; falling back to manual instructions", "reason", err)
215+
m.fallBackToUserAction(plan.fallback)
216+
default:
217+
// A user decline (ErrPromptDeclined) or any other prompt failure
218+
// ends the flow.
201219
m.logger.Debug("authorization prompt closed", "reason", err)
202220
cancel()
203221
}
@@ -208,6 +226,26 @@ func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan *
208226
m.complete(tok, err)
209227
}
210228

229+
// fallBackToUserAction promotes a running secure flow to the manual user-action
230+
// channel after its prompt could not be delivered. The background flow keeps
231+
// running, so the user can complete authorization out of band and retry. It is a
232+
// no-op if the flow has already resolved.
233+
func (m *Manager) fallBackToUserAction(ua *UserAction) {
234+
m.mu.Lock()
235+
defer m.mu.Unlock()
236+
if m.status != statusInProgress {
237+
return
238+
}
239+
m.status = statusAwaitingUser
240+
m.pending = ua
241+
// Wake any callers joined on this flow so they receive the action, and clear
242+
// done so complete() does not double-close it when run() later finishes.
243+
if m.done != nil {
244+
close(m.done)
245+
m.done = nil
246+
}
247+
}
248+
211249
// complete records the flow result, installing a refreshing token source on
212250
// success, and wakes any joined callers.
213251
func (m *Manager) complete(tok *oauth2.Token, err error) {
@@ -236,16 +274,22 @@ func (m *Manager) complete(tok *oauth2.Token, err error) {
236274
}
237275
}
238276

239-
// joinWait blocks until the running flow finishes or ctx is cancelled.
277+
// joinWait blocks until the running flow finishes or ctx is cancelled. If the
278+
// flow was promoted to the manual channel while waiting (its prompt could not be
279+
// delivered), it returns that user action rather than an error.
240280
func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, error) {
241281
select {
242282
case <-done:
243283
if m.AccessToken() != "" {
244284
return nil, nil
245285
}
246286
m.mu.Lock()
287+
pending := m.pending
247288
err := m.lastErr
248289
m.mu.Unlock()
290+
if pending != nil {
291+
return &Outcome{UserAction: pending}, nil
292+
}
249293
if err != nil {
250294
return nil, err
251295
}

internal/oauth/manager_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,42 @@ func TestAuthenticateDeclinedPromptFails(t *testing.T) {
117117
assert.Empty(t, m.AccessToken())
118118
}
119119

120+
func TestAuthenticateUndeliverablePromptFallsBack(t *testing.T) {
121+
f := newFakeGitHub(t)
122+
m := newManager(t, f)
123+
m.openURL = func(string) error { return errors.New("no browser") }
124+
125+
// The client advertised URL elicitation but delivering the prompt fails (a
126+
// transport/protocol error, not a user decision). This must degrade to the
127+
// manual instructions rather than aborting like a decline does.
128+
prompter := &fakePrompter{
129+
urlCapable: true,
130+
onURL: func(_ context.Context, _ Prompt) error {
131+
return ErrPromptUnavailable
132+
},
133+
}
134+
135+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
136+
defer cancel()
137+
138+
out, err := m.Authenticate(ctx, prompter)
139+
require.NoError(t, err, "an undeliverable prompt must not abort the flow")
140+
require.NotNil(t, out)
141+
require.NotNil(t, out.UserAction, "an undeliverable prompt must fall back to a user action")
142+
assert.NotEmpty(t, out.UserAction.URL)
143+
assert.Contains(t, out.UserAction.Message, securityAdvisory)
144+
145+
// A concurrent retry while awaiting the user returns the same fallback action.
146+
out2, err := m.Authenticate(ctx, nil)
147+
require.NoError(t, err)
148+
require.NotNil(t, out2.UserAction)
149+
assert.Equal(t, out.UserAction.URL, out2.UserAction.URL)
150+
151+
// The background flow stayed alive: opening the URL out of band completes it.
152+
require.NoError(t, browserGet(out.UserAction.URL))
153+
assert.Equal(t, "gho_access", waitForToken(t, m))
154+
}
155+
120156
func TestAuthenticateLastDitchUserAction(t *testing.T) {
121157
f := newFakeGitHub(t)
122158
m := newManager(t, f)

internal/oauth/prompter.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ import (
55
"errors"
66
)
77

8-
// ErrPromptDeclined is returned by a Prompter when the user cancels or declines
9-
// the authorization prompt.
8+
// ErrPromptDeclined is returned by a Prompter when the user actively cancels or
9+
// declines the authorization prompt. It is a deliberate "no", so the flow stops
10+
// rather than falling back to another channel.
1011
var ErrPromptDeclined = errors.New("authorization declined by user")
1112

13+
// ErrPromptUnavailable is returned by a Prompter when the prompt could not be
14+
// delivered at all — for example the client advertised an elicitation capability
15+
// but the request failed at the transport or protocol level. Unlike
16+
// ErrPromptDeclined it reflects no user decision, so the flow falls back to a
17+
// channel that needs no client capability instead of giving up.
18+
var ErrPromptUnavailable = errors.New("authorization prompt could not be delivered")
19+
1220
// Prompt is the content shown to the user when asking them to authorize.
1321
type Prompt struct {
1422
// Message is a human-readable instruction.
@@ -36,15 +44,17 @@ type Prompter interface {
3644
// until the user acknowledges, declines, or ctx is done. Returning nil means
3745
// the prompt was shown (not that authorization completed); the caller waits
3846
// for the OAuth flow itself to finish. It returns ErrPromptDeclined if the
39-
// user declines or cancels.
47+
// user declines or cancels, or ErrPromptUnavailable if the prompt could not
48+
// be delivered.
4049
PromptURL(ctx context.Context, p Prompt) error
4150

4251
// CanPromptForm reports whether the client supports form elicitation, used
4352
// to display a device code when URL elicitation is unavailable.
4453
CanPromptForm() bool
4554

4655
// PromptForm presents a textual acknowledgement prompt and blocks until the
47-
// user responds. It returns ErrPromptDeclined if the user declines.
56+
// user responds. It returns ErrPromptDeclined if the user declines, or
57+
// ErrPromptUnavailable if the prompt could not be delivered.
4858
PromptForm(ctx context.Context, p Prompt) error
4959
}
5060

0 commit comments

Comments
 (0)