-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(client): make streamable HTTP auth awaits abortable by requestSignal #2644
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
base: main
Are you sure you want to change the base?
Changes from all commits
c37f361
bdacc42
2d4fbe7
f4e9e33
ce28a32
123e3e9
61062b0
53c393c
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 |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Make the streamable HTTP transport's auth awaits abortable. `AuthProvider.token()`, `onUnauthorized()` 401 recovery, and insufficient-scope step-up authorization were awaited with no way for `TransportSendOptions.requestSignal` (or the transport's own lifetime signal) to reach them, so a hung token refresh or recovery flow parked `send()` forever past its abort. These awaits are now raced against the combined request/transport signal, and the signal is offered to `onUnauthorized` via the new optional `UnauthorizedContext.signal` field so cooperative providers can cancel their own recovery work. An abort during the auth chain rejects the send with the abort reason (unstamped, treated as an intentional teardown, no spurious `onerror`). Also fixes resume-via-`send()`: the resumed GET now preserves the caller's `onresumptiontoken`/`onRequestStreamEnd` observers, so resumed streams keep the token-persistence chain, report their terminal end, and an outright resume failure no longer dead-ends silently. Reconnect-attempt failures now reach `onerror` exactly once, as the underlying error — the `Failed to reconnect SSE stream: …` wrapper that duplicated each per-attempt report is gone (the `Maximum reconnection attempts (N) exceeded.` exhaustion message is unchanged). A reconnect scheduled before the resumed stream delivered any ID-bearing event now falls back to the stream's prior resumption token instead of silently dropping `Last-Event-ID`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,14 @@ export interface UnauthorizedContext { | |
| serverUrl: URL; | ||
| /** Fetch function configured with the transport's `requestInit`, for making auth requests. */ | ||
| fetchFn: FetchLike; | ||
| /** | ||
| * Abort signal for the request (or transport) whose 401 triggered this | ||
| * recovery. The transport stops waiting for `onUnauthorized` when it | ||
| * aborts; cooperative implementations should pass it to their own fetches | ||
| * so the recovery work stops too. Optional — absent when the transport | ||
| * has no lifetime signal to offer. | ||
| */ | ||
| signal?: AbortSignal; | ||
|
Comment on lines
+58
to
+65
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. 🟣 The legacy SSE client transport ( Extended reasoning...What survives in the sibling transport. This PR races every auth await in
Why the transport-lifetime wedge applies. Step-by-step proof. (1) Why this is in scope to mention. The repo's Completeness convention says: when a PR replaces a pattern, grep the package for surviving instances of the old form — partial migrations leave sibling code paths with the very bug the PR claims to fix (#1657, #1761, #1595). Additionally, the PR modifies the shared Why it is not blocking. All three verifiers agreed on pre-existing severity: How to fix (follow-up). Give
claude[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
|
|
||
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.
🟡 The changeset declares
patch, but this PR adds new public API to@modelcontextprotocol/clientat stable 2.0.0:UnauthorizedContextis newly exported from the package barrel, and the new optionalUnauthorizedContext.signalfield is a runtime-populated extension point the docs teach provider authors to adopt — under semver that is aminorbump. Consider changing the front-matter tominor(or splitting the API addition into its own minor changeset) so the new extension point isn't buried in a patch entry.Extended reasoning...
What the issue is.
.changeset/abortable-auth-awaits.mddeclares'@modelcontextprotocol/client': patch, but the PR's final shape adds consumer-visible API surface to a package sitting at stable2.0.0(packages/client/package.json):packages/client/src/index.tsnewly exports theUnauthorizedContexttype (added in review round f4e9e33). Per CLAUDE.md § Public API Exports, "Adding a symbol to a packageindex.tsmakes it public API" — andgit show HEAD~10confirms the barrel had zero matches before this PR, so this is the type's nameable public debut.packages/client/src/client/auth.tsadds the optionalUnauthorizedContext.signalfield, and both transport paths (_sendand_startOrAuthSse) now actually populate it. This is not a type-only tweak: it is a new runtime capability thatdocs/clients/machine-auth.mdanddocs/migration/upgrade-to-v2.mdboth explicitly teach provider authors to adopt ("forward it to your own fetches so the recovery work stops with it").Step-by-step proof that this is the standard minor-bump marker. (1) A provider author writes
onUnauthorized: ctx => { myFetch(url, { signal: ctx.signal }) }. (2) Against2.0.0, this fails to typecheck —UnauthorizedContexthas nosignalproperty and the type isn't even importable from the barrel. (3) Against the version this changeset produces, it typechecks and works at runtime. (4) Code that compiles against 2.0.x-latest but not against 2.0.0 is precisely semver's definition of "new, backwards compatible functionality" — a MINOR bump. With the current changeset,2.0.1would ship with an API surface that differs from2.0.0, which API-diff tooling and consumers treating patch releases as surface-identical will misclassify.Repo precedent cuts both ways — here is the honest accounting. The package's own changelog records PR #1710 (which introduced the
AuthProvidersurface, including the line "NewUnauthorizedContexttype.") under Minor Changes. A refuting verifier correctly countered thatpackages/client/CHANGELOG.md's 2.0.0 Patch Changes section contains bugfix PRs with incidental new API declared as patch: #2441 shipped the newisJsonContentType()export, and #2384 shipped the newX.isInstance()static guards. Both claims verify. However, both of those patch declarations were made during the 2.0.0-beta prerelease phase (they first shipped in2.0.0-beta.3), where the bump type had no user-visible consequence — every prerelease bump lands as another-beta.Nregardless. The same is true of #1710's minor entry (2.0.0-alpha.1). This PR is the first such incidental API addition landing against a stable 2.0.0, where patch vs. minor is the difference between2.0.1and2.1.0and actually governs what consumers may assume. So the prerelease-era "bump tracks the change's primary character" precedent doesn't straightforwardly transfer, and strict semver applies with full force for the first time.Impact. Beyond tooling misclassification, the release notes generated from a patch changeset bury the one thing provider authors must adopt for cooperative cancellation — the
ctx.signalextension point — in a patch entry, where nobody scanning Minor Changes for new capabilities will find it.How to fix. One word in the front-matter:
patch→minor. Alternatively, split the additive API (UnauthorizedContextexport +signalfield) into its ownminorchangeset and keep the abort-race bugfix aspatch— changesets takes the max, so the release lands as minor either way, with cleaner notes.Why nit. Release-metadata correctness only — nothing breaks at runtime if this merges as-is, and given the (prerelease-era) patch precedent the maintainers may reasonably make the opposite call. Non-blocking either way.