-
Notifications
You must be signed in to change notification settings - Fork 371
fix(mcp): stop the OAuth Authentication Request loop after the user clicks Cancel #2949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -480,6 +480,14 @@ func (t *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) { | |
|
|
||
| if !e.ts.IsStarted() { | ||
| if err := e.ts.Start(ctx); err != nil { | ||
| // Diagnostic breadcrumb so the deferred-vs-declined | ||
| // classification is visible in --debug logs without | ||
| // having to instrument the OAuth transport itself. | ||
| slog.DebugContext(ctx, "Enabled remote MCP server failed to start", | ||
| "id", e.id, | ||
| "auth_required", mcp.IsAuthorizationRequired(err), | ||
| "oauth_declined", mcp.IsOAuthDeclined(err), | ||
| "error", err) | ||
| // Auth-required is an *expected* deferral when probing | ||
| // with a non-interactive context (startup tool count) or | ||
| // when the elicitation bridge is not yet ready. Silent | ||
|
|
@@ -490,6 +498,18 @@ func (t *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) { | |
| "id", e.id) | ||
| continue | ||
| } | ||
| // User explicitly dismissed the host's authorization | ||
| // dialog (clicked "Cancel" / "Decline"). Treat that as | ||
| // "deactivate this server" so the very next Tools() | ||
| // call does not re-fire the OAuth flow and re-pop the | ||
| // dialog the user just closed. The model can re-enable | ||
| // the server explicitly if the user changes their mind. | ||
| if mcp.IsOAuthDeclined(err) { | ||
| slog.InfoContext(ctx, "Remote MCP server OAuth declined by user; removing from enabled set", | ||
| "id", e.id) | ||
| t.disableAfterDecline(ctx, e.id, e.ts) | ||
| continue | ||
| } | ||
| // Real failure: log once per streak (StartableToolSet | ||
| // dedupes) so a misbehaving server doesn't flood logs. | ||
| if e.ts.ShouldReportFailure() { | ||
|
|
@@ -703,7 +723,9 @@ func (t *Toolset) handleEnable(ctx context.Context, args EnableArgs) (*tools.Too | |
| } | ||
|
|
||
| msg := strings.Builder{} | ||
| fmt.Fprintf(&msg, "enabled %q (%s) — the server's tools will appear on your next turn. Proceed with the user's original request; the host handles any required connection setup.\n", id, server.Title) | ||
| fmt.Fprintf(&msg, "enable requested for %q (%s). The host will surface any required authorization dialog. On your next turn:\n", id, server.Title) | ||
| fmt.Fprintf(&msg, " - if tools whose names start with %q appear in your available tools, proceed with the user's original request using them.\n", id+"_") | ||
| fmt.Fprintf(&msg, " - if NO such tools appear, the user dismissed the authorization dialog. Do NOT claim the server is connected and do NOT ask the user for workspace details to use \"the available tools\" (there are none). Tell the user the request needs them to authorize the connection. If the user then re-asks for the same thing (or says \"yes\", \"retry\", \"try again\", etc.), call %s for this id again to surface a fresh authorization dialog.\n", ToolNameEnable) | ||
| fmt.Fprintf(&msg, "endpoint: %s\n", server.URL) | ||
| // Only surface unresolved env vars — the model can act on those by | ||
| // asking the user to set the variable and retrying. Every other auth | ||
|
|
@@ -796,6 +818,43 @@ type DisableArgs struct { | |
| ID string `json:"id" jsonschema:"Catalog id of the server to disable."` | ||
| } | ||
|
|
||
| // disableAfterDecline removes a server from the enabled set after the user | ||
| // declined its OAuth flow, mirroring handleDisable's lifecycle invariants | ||
| // (delete from t.enabled, stop the inner toolset, notify the runtime that | ||
| // the tool surface changed) but without producing a model-facing tool | ||
| // result — the decline happens asynchronously inside Tools(), not in | ||
| // response to a model tool call. | ||
| // | ||
| // The guard (current == wrapped) protects against a concurrent | ||
| // handleDisable / handleResetAuth / handleEnable that already swapped or | ||
| // removed the entry between the failing Start() and this cleanup. | ||
| func (t *Toolset) disableAfterDecline(ctx context.Context, id string, wrapped *tools.StartableToolSet) { | ||
| t.mu.Lock() | ||
| current, stillEnabled := t.enabled[id] | ||
| if stillEnabled && current == wrapped { | ||
| delete(t.enabled, id) | ||
| } else { | ||
| // Already gone (or replaced by a fresh enable) — let whoever | ||
| // owns the live wrapper handle its lifecycle. We still want | ||
| // to stop OUR reference because Start() may have left the | ||
| // session in a partially-initialised state. | ||
| stillEnabled = false | ||
| } | ||
| notify := t.toolsChangedHandler | ||
| t.mu.Unlock() | ||
|
|
||
| if err := wrapped.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LOW] The error filter in if err := wrapped.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.DebugContext(ctx, "Failed to stop declined-OAuth remote MCP toolset", ...)
}only swallows
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See previous reply — duplicate of the same observation. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the pointer! I see you've already addressed this in the other thread — agreed that changing all three callsites together (ideally with a shared helper) makes more sense than creating asymmetry here. Closing this duplicate. |
||
| slog.DebugContext(ctx, "Failed to stop declined-OAuth remote MCP toolset", | ||
| "id", id, "error", err) | ||
| } | ||
|
|
||
| // Only notify when WE removed the entry; if a concurrent caller | ||
| // already mutated t.enabled, they will have notified themselves. | ||
| if stillEnabled && notify != nil { | ||
| notify() | ||
| } | ||
| } | ||
|
|
||
| func (t *Toolset) handleDisable(ctx context.Context, args DisableArgs) (*tools.ToolCallResult, error) { | ||
| id := strings.TrimSpace(args.ID) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[LOW]
disableAfterDeclinesuppressescontext.Canceledbut notcontext.DeadlineExceededfromStopThe error filter in
disableAfterDecline:only swallows
context.Canceled. If the context has a deadline that expires whileStopis running,context.DeadlineExceededproduces a spurious debug log. The same pattern appears inhandleDisable(line 871) andhandleResetAuth(line 949) — for consistency consider usingerrors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)(or a shared helper).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Intentionally left alone for consistency.
The error filter mirrors the existing pattern already used by
handleDisable(around line 871) andhandleResetAuth(around line 949). ChangingdisableAfterDeclinein isolation to also swallowcontext.DeadlineExceededwould introduce a behavioural asymmetry between the three callers that all share the same "Stop on the way out, do not yell on caller-induced cancellation" intent.If the broader pattern should accept
DeadlineExceededtoo, that is a focused follow-up touching all three callsites (and arguably a shared helper), not an asymmetric tweak in this PR.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're absolutely right — I should have framed this as a consistency issue across all three sites rather than singling out
disableAfterDecline. Making the change in isolation would indeed create an asymmetry, and your reasoning about "caller-induced cancellation" applying equally to bothCanceledandDeadlineExceededmakes sense.A follow-up PR that introduces a small helper (e.g.,
isContextDone(err) bool) and updates all three callsites would be cleaner. That said, if the current pattern hasn't caused issues in practice, it's a low-priority polish item rather than a bug. Thanks for the context!