inspector/core/mcp/inspectorClient.ts
Cliff Hall c2af547cc8
fix(core): register peer-request handlers before connect (#1797) (#1798)
* fix(core): register peer-request handlers before connect (#1797)

The roots/sampling/elicitation capabilities are advertised on the SDK
`Client` at construction, so from the moment `connect()` sends
`notifications/initialized` the server may issue any of those requests.
The handlers, however, were registered after the handshake resolved and
after `fetchServerInfo()` — leaving a window in which the SDK answered
`-32601 Method not found`.

`server-filesystem` asks for `roots/list` the instant it is initialized
(that is how it learns its allowed directories) and lost the race, so it
silently fell back to its CLI-argument directories instead of the roots
configured in the Inspector. `server-everything` asks later and did not.

Extract the four peer-request handler blocks into
`registerPeerRequestHandlers()` and call it before `client.connect()`.
None of them depend on server capabilities — only on constructor-set
state. The notification handlers that do gate on `this.capabilities` stay
in `connect()`, after `initialize`.

Regression test drives the race deterministically with a fake transport
that delivers `roots/list` synchronously from inside the `send()` of
`notifications/initialized`; it fails on the pre-fix ordering.

Closes #1797

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-1 fixes — pre-connect roots/list_changed, wider timing test (#1797)

Finding 1: `notifications/roots/list_changed` was the one handler in the
post-handshake block that gates on nothing but constructor state, so it
sat in the same race — a server emitting it right after `initialized` had
it silently dropped (no wire error; the symptom is a missed UI refresh).
Moved to a new `registerPeerNotificationHandlers()` called next to
`registerPeerRequestHandlers()` before `connect()`.

Finding 2: the timing test asserted one of the four moved handler blocks.
It now injects `tasks/list` and the `roots/list_changed` notification in
the same burst as `roots/list`, so moving any of the block back after
`connect()` fails the test. Sampling/elicitation stay out — they park a
pending request awaiting user input and have no reply to assert on.
Dropped the unused `sessionId`/`setProtocolVersion` transport fields.

Verified the added assertions are load-bearing: the notification
assertion fails against the previous commit, the `roots/list` one against
`origin/v2/main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-2 fixes — correct the roots/list_changed premise (#1797)

The round-1 doc comment claimed a server may emit
`notifications/roots/list_changed` at initialize. It can't, normally:
`RootsListChangedNotificationSchema` is a member of
`ClientNotificationSchema` and absent from `ServerNotificationSchema` —
the client sends it (setRoots does, at :4251). The inbound handler is
defensive coverage for a non-conformant server, and dispatches
`rootsChange` with our own already-known roots. Reworded the method doc
and the test's header so neither states the false premise; the move
itself stands (it gates on no server capability, so it belongs with the
request handlers).

Also from review:
- Dropped the vacuous `notificationRejected` flag and its inverted
  comment. The SDK does not throw for an unregistered notification, so
  the try/catch could never observe one — confirmed by bisect, where the
  pre-fix failure was always `rootsChanges`, never the flag. Removing the
  catch also stops it swallowing a genuine throw from the injected
  requests.
- Narrowed `replies` to responses (`"result" in message || "error" in
  message`) so it can't also collect client-originated requests.
- Annotated the unreachable `if (!this.client)` guard with a justified
  `v8 ignore` per the AGENTS.md dead-guard policy.

Re-verified both regression directions: the `roots/list` assertion fails
against `origin/v2/main`, the `rootsChange` one against `55e25b7c`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-3 fix — drop stale comments inside the roots handler (#1797)

The four inline comments moved verbatim with the handler contradicted the
method doc reworded one round earlier: they said "server's roots" (they
are ours — it is a ClientNotification) and "we'll just dispatch an empty
array", which is not what `this.roots || []` does when roots are
configured. The new timing test asserts exactly that non-empty dispatch.
Replaced with one accurate line. Comment-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(cli): advertise roots so roots/set is actually serviceable (#1797)

Round-4 review found the same -32601 symptom on a second path: the CLI
constructed its InspectorClient with no `roots` option, so
`capabilities.roots` was never advertised and no `roots/list` handler was
registered — yet `--method roots/set` calls `setRoots()`, which announces
`notifications/roots/list_changed` to the server. A server taking up that
invitation got "Method not found", after the CLI reported success.

The review's suggested fix — register `roots/list` unconditionally — is
not implementable: the SDK asserts the client capability inside
`setRequestHandler` ("Client does not support roots capability"), so it
throws during connect for any client built without the option, and
`registerCapabilities` refuses to run after connect. A client that omits
`roots` therefore cannot legally serve `roots/list` at all.

Fixed at the layer that can fix it: `cli.ts` seeds `roots: []`, matching
web (`App.tsx` always passes it). The core registration stays gated on
the constructor value, now with the SDK constraint documented so the next
reader doesn't retry the unconditional version.

`setRoots()`'s doc claimed it "will enable roots capability if it wasn't
already enabled" — it cannot, for the reasons above. Corrected, and
dropped the dead `if (this.roots === undefined) this.roots = []` the very
next line overwrote.

Tests: a CLI test drives `list_roots` over an HTTP test server (verified
load-bearing — without the seed it fails on the -32601 error text), and
the core timing test gains a case pinning that roots set after connect
are served with the current values, not the constructor's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(cli,tui): advertise the roots configured in mcp.json (#1797)

Round-5 review: seeding `roots: []` fixed the -32601 but not the outcome.
`server-filesystem` discards an empty roots answer (`validatedRootDirs
.length > 0`) and keeps its CLI-argument directories — which is the
symptom this PR set out to fix. The user's roots were already in hand two
lines away, on `serverSettings.roots` (lifted from mcp.json by
`mcpConfigToServerEntries`). Now passed through `cleanRoots`, the same
shared helper web uses, so both clients answer `roots/list` byte-identically
for the same config.

This matters most on the CLI because it is one-shot: `--method roots/set`
sets roots on a connection torn down a few statements later, so the config
file is the only durable way to give a run its roots.

Same gap in the TUI (`App.tsx` built its options with no `roots` key at
all), fixed the same way. The review suggested a follow-up issue for it;
folding it in here instead, since it is the identical line and splitting
it would leave a known-wrong client in tree.

Also from review: hoisted `waiters` up with the other fields, and
`injectRequest` now rejects after 1s naming the method and id rather than
hanging to the vitest timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-6 fixes — document roots in the CLI README, harden cleanRoots (#1797)

- clients/cli/README.md enumerated the settings lifted from a catalog/config
  file without roots. Roots are the least discoverable member of that list —
  there is no flag, and `--method roots/set` dies with the connection — so
  the file is the only durable way to supply them. Said so.
- cleanRoots() trusted its input's shape. `Root[]` is a compile-time type over
  hand-editable mcp.json, and as of this PR all three clients feed it straight
  from disk, so `"roots": [{"name":"Work"}]` or `"roots": "file:///work"` threw
  at connect. Non-array bails to []; an entry without a string uri is dropped
  with a warning. Hardened in the one shared place rather than three call
  sites. Review suggested a follow-up issue; folded in for the same reason as
  the TUI fix last round.
- Test comment said the CLI passes a `roots: []` seed — true one commit ago.
- injectRequest clears its timeout on reply (an armed timer outliving a test is
  the #1760 teardown class), and setRoots' doc drops the "despite what this
  comment used to claim" archaeology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-7 fix — cleanRoots also throws on a non-string name (#1797)

The round-6 hardening guarded `uri` and left `name` throwing one line
later: `r.name?.trim()` guards null/undefined, not "is a string", so
`"roots": [{"uri":"file:///a","name":42}]` still died at connect — the
same crash class, one field over, reachable from all three clients and
the web settings-save path. Reproduced against the shipped function body
before fixing.

A bad `name` now costs the name, not the root (it is optional, and the
uri is still usable), with a warning. The doc no longer overstates what
is validated.

Also from review: the CLI test comment said "`cli.ts` now seeds
`roots: []`" — true only of that ad-hoc test's path; and the
registration comment's `(:588)` line reference is now a durable name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): normalize roots in setRoots() too (#1797)

Round-8 review: this PR made cleanRoots the single normalizer for roots at
connect and at save, but setRoots — the third way roots enter the client —
did a bare `this.roots = [...roots]`. Web cleans at its call site; the CLI
does not, so `--method roots/set --roots-json '[{"name":"Work"}]'` stored a
root with no uri, advertised it in the roots/list reply, and echoed it back
as success. cleanRoots is idempotent, so calling it inside setRoots covers
the CLI without disturbing web's pre-clean, and all three entry points now
agree.

Review filed this as an optional follow-up; folding it in for the same
reason as the TUI and cleanRoots items — it is smaller than the issue
describing it.

Test asserts the malformed entry is dropped and the valid one survives;
verified load-bearing against the bare-spread version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-9 fixes — normalize roots in the constructor too (#1797)

The round-8 commit claimed "all three entry points now agree"; review
pointed out the constructor is a fourth, and the only one still raw. All
three clients clean at their call site, so nothing was broken — but the
invariant was enforced by convention rather than by the class, at the very
entry point this whole thread started from. `this.roots` now runs through
cleanRoots, with a ternary so the `undefined`-vs-`[]` distinction that
gates capabilities.roots survives.

Also from review:
- The CLI roots/set test provoked a cleanRoots warning without suppressing
  it (AGENTS.md). Suppressed, and the spy now asserts the drop was
  reported rather than silent.
- `enables roots when previously undefined …` in coverage-backfill was
  named after behaviour round 5 established does not exist. Renamed, with
  a comment on what that client actually can and cannot do.
- setRoots dispatches a copy to rootsChange, as getRoots() already did, so
  a listener can't push into the list we advertise.
- run-method.test.ts's connectStdio now passes `roots: []` like cli.ts, so
  the helper matches the client shape production builds.

Verified load-bearing: the constructor test fails against the raw
assignment. validate / coverage / build-gate / smoke / storybook all green
(run as separate stages — full `npm run ci` kept being killed mid-coverage
by the environment).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-10 fixes — copy at both rootsChange dispatches (#1797)

Round-9 claimed setRoots' copy closed the "a listener can't push into the
list we advertise" hole. It closed one of two sites: my reply conflated
the notification handler's *dispatch* with the roots/list *request*
handler — the latter is serialized to the wire immediately and is fine,
the former hands listeners the internal array. Both copy now.

Also from review: dropped the `({}) as never` in the new constructor test
for the real factory the sibling tests use (this test never connects, so
it is never invoked), and the needless `async` on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-11 doc fixes; merge v2/main (#1797)

- cleanRoots' opening sentence still framed it as the settings-form
  normalizer, contradicting the paragraph below it that says every client
  feeds it from disk. It has four callers now; says so.
- Marked the two runtime guards `Keep:` with the reason. The `Root[]`
  parameter narrows both to `never`, so "the compiler says this can't
  happen" is the argument someone will use to delete the checks rounds 6-7
  installed. The reason now lives where the deletion would happen.
- Noted the constructor-normalization case in the timing test's header, so
  it is discoverable from a file otherwise about the initialized race.

Also merged origin/v2/main (#1796, loopback binding), which had advanced
by one commit and touched the smoke scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): clear queued peer requests when connect() fails (#1797)

Round-12 review found a second-order consequence of the handler move.
Before it, registration happened after every await in connect(), so the
moment a peer request could be queued the connect could no longer fail —
queue and failure path never overlapped. Registering before the handshake
puts both the handshake and the post-connect `setLoggingLevel` inside that
window.

The catch sets status to error and drops the transport but never cleared
pendingSamples/pendingElicitations; only disconnect() does, and connect()
reaches it solely on the connect-timeout path (not the default). So a
server that elicits at `initialized` and then fails the connect left a
live pending-request modal on a dead connection — web derives it from the
queue lengths with no status gate — and the elicitation's cancel() never
fired, hanging any awaitUrlElicitation waiter.

Extracted disconnect()'s clear-and-cancel into clearPendingPeerRequests()
and called it from the catch, dispatching the change events so the UI
drops the modal. CLI/TUI are unaffected either way (sample/elicit off).

Test note: the first version of this test passed with and without the fix
— its `requestedSchema` omitted `properties`, so the SDK rejected the
inbound params and nothing was ever queued. Fixed the fixture; it now
fails without the change with a queued ElicitationCreateMessage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): clear the peer-request queue on a mid-session crash too (#1797)

Round-13 review, two items.

The test asserted only `getPendingElicitations()`/`getPendingSamples()`,
which read the arrays directly — but the arrays are not what removes the
modal. `usePendingClientRequests` tracks its own state off the
pendingSamplesChange/pendingElicitationsChange events, so a regression that
cleared without dispatching would strand a live modal on a dead connection
and stay green. Both tests now assert the events fired with an empty
payload; verified by deleting just the dispatches, which fails them.

The connect-failure fix left the other way a connection ends uncovered:
a mid-session crash goes through the transport's `onclose`, which sets
status and fires `disconnect` but never touched the queues. Same two
hazards, and arguably more reachable. `clearAndAnnouncePendingPeerRequests`
wraps the clear plus its events, guarded on a non-empty queue so the paths
that overlap (a connect failure whose dropCachedTransport also fires
onclose) announce once; called from both. New test queues an elicitation,
fires onclose, and asserts the queue and the event — fails without the
change.

Review flagged the crash path as a follow-up; folding it in, as with the
earlier out-of-scope items — it is the same seam and three lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-14 fixes — make both teardown paths agree (#1797)

- The crash path's comment claimed "cleared before the `disconnect` event
  so a consumer sees an empty queue" while disconnect() cleared *after*
  its own dispatch — an invariant stated as a property and held at one of
  two sites, the same shape as the round-10 finding. Moved disconnect()'s
  clear above its status/disconnect block so both paths agree; its change
  events stay batched with the other teardown dispatches. Full gate green,
  so nothing depended on the old ordering.
- clearPendingPeerRequests' doc still named the `connect()` failure path as
  a caller; that path now goes through clearAndAnnouncePendingPeerRequests.
  Names the two real callers.
- The mid-session test waited on `await Promise.resolve()` — the
  microtask-counting pattern this file replaced with injectRequest two
  rounds ago, working today only because the enqueue happens exactly one
  tick deep. Waits on the client's own `newPendingElicitation` instead.
- The file header still called the constructor case "the last case"; it is
  third of five, and the two teardown cases went undescribed. Rewritten.
- Restored the missing blank line between two tests, and noted why the
  sampling-event assertion holds for a queue that never held anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-15 fixes — scope the parity claim to the arrays (#1797)

Round-14 made the two teardown paths agree on when the queue is cleared,
and the comment then claimed parity flatly. It holds for the arrays but
not the events: the crash path announces before `disconnect`, while
disconnect() batches its change events with the rest of its teardown, so
they land just after. Since the events are what drives the modal, the
distinction matters to the next reader. Scoped the comment rather than
un-batching — un-batching would trade a documented ordering for a real
behaviour change with no gain.

Same rule as round 14, one level down: check the other site, then check
the other axis.

Also gave the new `newPendingElicitation` wait the timeout that
`injectRequest` documents, so a regression that stops the enqueue fails at
the wait instead of hanging to the vitest timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* test: pin disconnect()'s clear-before-announce ordering (#1797)

Round-14 moved clearPendingPeerRequests() above disconnect()'s status
block so a `disconnect` consumer sees an empty queue on both teardown
paths, and round 15 documented it — but nothing enforced it. The existing
`disconnect` listeners only count events, and the two teardown tests cover
the crash and connect-failure paths, neither of which goes through
disconnect(). So the one behavioural change in the stack was untested in
both directions, and the tidy that undoes it (regrouping the clear with
the batched change events) would have stayed green.

Test records the queue length from inside a `disconnect` listener; the
fixture's close() never fires onclose, so it exercises disconnect()'s own
ordering rather than the crash path. Verified against the pre-round-14
position: `expected 1 to be +0`.

Folded the newPendingElicitation wait into a shared helper now that two
tests use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* test: pin disconnect()'s announce as well as its clear (#1797)

Round-16 pinned the array half of disconnect()'s teardown and left the
events unpinned — and nothing else in the suite covers them, so deleting
disconnect()'s two change-event dispatches would have left a live modal
after a user-initiated disconnect with the suite green. That is the path
most likely to have a modal open when it runs. Asserted after the await
rather than inside the disconnect listener, since disconnect() batches the
events deliberately; verified by deleting the dispatches (`expected
undefined to be +0`).

Header rewritten by category rather than position: it has now gone stale
twice in three rounds because it counted tests ("the last two") and
enumerated paths that a new test falsified. It names the three teardown
paths and what distinguishes them instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-18 doc fixes — scope the three-path claim, re-attribute cancel() (#1797)

The header I rewrote last round claimed all three teardown paths clear
before announcing. True only if "announcing" means the `disconnect` event:
the connect-failure path emits none at all, and both it and the crash path
clear after their `statusChange`. Scoped to what's true. Same shape as
rounds 10/14/15 — a parity asserted in prose that holds on one of the axes
it could mean — which is now the fourth time, in a sentence I wrote.

ElicitationCreateMessage.cancel()'s doc still credited `disconnect()`
teardown; this PR made clearPendingPeerRequests() its only caller, serving
three paths. Re-attributed, and promoted the no-`onRemove` note to its own
paragraph with the consequence spelled out — it quietly became a
three-caller invariant during this PR, and "don't splice mid-iteration"
reads like a style note until you know it would skip every other entry and
leave those promises unsettled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): gate roots/list on what was advertised, not on this.roots (#1797)

Round-19 review found a latent wedge, reproduced before fixing: a client
constructed without `roots` that later calls setRoots() has this.roots
defined, so registerPeerRequestHandlers tried to register the handler on
every subsequent connect() — and the SDK throws "Client does not support
roots capability" from setRequestHandler when the capability was never
advertised. That throw lands before the handshake, so the client can never
reconnect. Worse than the -32601 this PR started from: the connection is
gone rather than answering one method badly.

Not a regression (the same gate lived after the handshake before) and
unreachable from the three shipped clients, which all pass roots. But
core/ is a library surface, and the failure is unrecoverable.

Gate now reads a construction-time `rootsCapabilityAdvertised` — the thing
the SDK actually asserts on — so this.roots stays free to change. setRoots'
doc says what does happen on such a client: the roots are stored and
readable, but no server can ask for them.

Also scoped the header's claim that the connect-failure path emits no
`disconnect`: true of connect()'s own catch, but on a real transport
dropCachedTransport's close() fires onclose first, which does dispatch one
— making the catch's own call the auth-recovery backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-20 — derive the roots gate from the advertisement itself (#1797)

Round-19's fix restored the invariant but not the derivation:
rootsCapabilityAdvertised was computed from `options.roots` alongside the
capability block rather than from it, so the two encodings of "did we
advertise roots" could drift. Adding any term to the advertisement
condition would leave the flag true with `capabilities.roots` absent —
the same unrecoverable wedge, reintroduced silently and in the throwing
direction.

Now read off the built capability object after it is assembled, so
divergence is structurally impossible rather than merely absent. Verified
the reconnect test still fails against the old `this.roots` gate.

Also: named the capability-lifetime category in the test header (the new
test was none of the three it listed), moved that test below the teardown
group so the group stays contiguous, and rewrapped the over-long comment
line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): gate elicitation/create on what was advertised too (#1797)

Round-21 review asked round-20's question of the capability next door, and
there the two derivations already disagreed — no future edit needed.
`elicit` is typed `boolean | { form?, url? }`, so `{}` or
`{ form: false, url: false }` is valid, enables no mode, and advertises no
`capabilities.elicitation` — while the registration gated on `this.elicit`
being truthy. Reproduced before fixing:

  CONNECT ERROR: Client does not support elicitation capability
                 (required for elicitation/create)

Since #1797 moved registration before the handshake, that throw means the
client cannot connect at all. Same wedge as the roots one, one capability
over, and reachable through a documented option value rather than a future
edit.

Gated on a new elicitationCapabilityAdvertised, derived from the built
capability object exactly as the roots flag now is. Test constructs with
`{ form: false, url: false }` and asserts connect() resolves; verified
load-bearing.

Also made the four construction-time capability inputs (sample, elicit,
receiverTasks, advertisedExtensions) `readonly`. The round-19 bug was a
mutator making a construction-time fact stale, so a future setter for any
of these should be a compile error rather than a review catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): derive tasks.requests and the remaining gates from the advertisement (#1797)

capabilities.tasks.requests declares which server→client requests we accept
as tasks, but was built from `receiverTasks` alone — so
`{ receiverTasks: true, elicit: false }` told the server it could send a
task-augmented elicitation/create while advertising no elicitation
capability and (since the last commit) registering no handler. A server
taking the invitation gets -32601: the advertise-then-refuse shape this PR
opened with, and the last place it survived. Now built from
capabilities.sampling / capabilities.elicitation, both decided just above,
with the key omitted entirely if neither applies.

Also made the remaining three registration gates read the built capability
object like the two from the last two rounds — sampling, tasks/*, and the
URL-elicitation completion handler. None could diverge today, but a mixed
convention means the next reader has to re-derive which gates are safe, and
adding one condition to an advertisement block is exactly how elicitation
broke. Five gates, one rule now.

Test asserts a receiverTasks+elicit:false client advertises tasks.requests
with sampling and without elicitation; verified load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-23 — pin the omitted-requests arm, drop the count again (#1797)

The `requests` key is omitted when neither sampling nor elicitation is
advertised, but every `receiverTasks: true` construction in the tree left
one of them enabled, so that arm was unexercised — and it would not have
shown as a coverage failure, since one uncovered branch in a ~4900-line
file sits well inside the per-file gate. Second client in the same test
asserts `tasks` survives (list/cancel are still serviceable) while
`requests` is absent rather than empty; verified against `requests:
taskRequests`.

The header regained a numeral four commits after round 17 rewrote it by
category to stop exactly that, and it was already stale. Dropped, and named
the third category — advertise-only-what-you-serve, which is about the
capability object rather than a registration gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): settle queued samples on teardown instead of discarding them (#1797)

Round-24 review. Round 13 recorded the samples-vs-elicitations asymmetry
as deliberate on two grounds: nothing internal awaits a sample, and the
transport is gone by then. The first still holds; the second is false on
one path, and it is a path this PR created. `connect()`'s catch drops the
transport only when `!transportHasAuthProvider`, so on the HTTP+OAuth path
— web's ordinary authenticated case — it is retained and the caller
reconnects over it. A sampling request queued during the handshake was
then discarded with the connection still live: no response frame ever
written, and the server waits forever for a reply to a request we
accepted. Accept-then-silence, which is worse for the peer than the
-32601 this PR opened with.

SamplingCreateMessage.cancel() mirrors the elicitation one — settles
(rejects, what the UI's decline path already sends and the SDK turns into
an error response) without calling onRemove, since the caller iterates the
queue and clears it. clearPendingPeerRequests loops it like the
elicitations.

Also corrected two comments that assumed the catch always tore the
transport down, and noted that the retention is gated on
transportHasAuthProvider alone — independently of
isConnectAuthRecoveryError, which gates only the status hold.

Test asserts settlement rather than a wire frame, since on the disconnect()
path the transport is closed before the settle; a later respond() throwing
"already resolved or rejected" is the proof. Verified load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): mark receiver-task payload promises handled (#1797)

Round-25 review. clearPendingPeerRequests iterates every entry in
pendingSamples, and two shapes live there: the plain server-request one,
whose reject reaches the SDK handler, and the task-augmented one, whose
reject reaches the receiver task's payloadPromise. That promise only gets
a consumer when the server polls tasks/result, so a task sitting in
input_required has no handler attached — and the settle added last commit
surfaced as an unhandled rejection.

Not hypothetical: the repo already worked around it test-side, with a
helper that reaches into private state to attach no-op catches, whose
comment describes this exact race. Fixed at the source instead — one
`void payloadPromise.catch(() => {})` at creation, which also covers the
pre-existing cancelReceiverTask path. Removed the now-redundant helper and
its two call sites. Verified: dropping the source line makes that suite
report two unhandled rejections.

Corrected SamplingCreateMessage.cancel()'s doc — its "no response frame is
ever written" rationale is true of the plain shape only; a task-augmented
sample already answered the wire with a CreateTaskResult, so settling it is
about not leaving the task in input_required limbo.

Nits: generalized the enqueue wait helper to both events so the sampling
wait gets the timeout the elicitation one has; moved the sampling teardown
test back into the contiguous teardown group; named the settle property in
the header; fixed the fixture doc that still claimed to record replies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-26 — drop the comment that outlived its call (#1797)

The suppressReceiverPayloadRejections call went last commit; its two-line
comment stayed, describing a pre-attach that no longer happens and
crediting the test for handling a rejection the source now handles.
Removed. Also reworded the surviving .catch() in inspectorClient.test.ts:
it is still needed, but as a value capture for the assertion, not for
unhandled-rejection suppression.

Rewrapped the teardown paragraph in the test header — my round-25 edit
left a 104-char line, and two hand-fixes left it ragged, so it is reflowed
in one pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-27 — skip the roots/list_changed the SDK would refuse (#1797)

Review reported setRoots() emitting roots/list_changed on a client that
never advertised roots, inviting a re-fetch we'd answer -32601. Probed it:
the premise is wrong. The SDK asserts the notification capability too —
`notification()` rejects with "Client does not support roots list changed
notifications" and nothing reaches the wire. No spec violation and no
invitation; what actually happened is that setRoots provoked that
rejection and logged it as "Failed to send", implying a transient failure
where the truth is structural.

Kept the early return on that corrected basis: it stops us provoking a
rejection we know will come, and warns accurately instead. Doc and comment
say the SDK is the enforcer rather than claiming this guard is what keeps
the wire clean.

The test pins the end state (no invitation for roots we can't serve) and
passes either way — recorded as such rather than presented as a regression
guard, since it isn't one.

Also moved the advertise-category test below the two gate cases, matching
the order the header introduces them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-28 — gate the notification on the predicate the SDK asserts (#1797)

The roots/list_changed guard read `capabilities.roots !== undefined`, but
the SDK asserts `capabilities.roots.listChanged` for the notification —
wider than the flag. Identical today (roots are only ever advertised as
`{ listChanged: true }`), but a conditional advertisement would un-gate it
silently, which is the round-21 shape again. Split into
rootsListChangedCapabilityAdvertised, mirroring
urlElicitationCapabilityAdvertised, which already reads its assertion's
sub-field; rootsCapabilityAdvertised keeps the roots/list registration,
where presence is what setRequestHandler checks.

Also moved the new test out of the gate pair it had split — it is a
runtime-emission case, not a registration-gate one — and extended the
header's converse-category paragraph to cover notifications, which indexes
it without inventing a category. Reflowed the setRoots doc paragraph in one
pass (111-char line from the last commit, plus a ragged orphan from an
earlier edit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-29 — chase the corrected premise into clients/cli (#1797)

Round 27 corrected "setRoots announces roots/list_changed to a server that
then gets -32601" — the SDK refuses the notification from a client that
never declared it, so nothing reached the wire. That reword happened in
core/, but the same premise had been written into the two comments
justifying the CLI's roots seed, and they still asserted it. Both now state
the real pre-fix failure: a server asking for roots on its own got -32601,
and roots/set could not announce at all.

Also fixed the warn message under the round-28 gate: it named the roots
capability while the gate is on roots.listChanged — precisely the pair that
split was made to distinguish, so a client advertising `{}` would have been
told it advertised nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): reject in-flight raw-wire requests on a mid-session crash (#1797)

rejectPendingRawWireRequests had one caller — disconnect() — so the crash
path settled the inbound peer-request queue this PR added and left the
outbound raw-wire map alone. That map holds the modern tasks/* frames the
SDK's era gate refuses to route, so the SDK's chained _onclose doesn't know
about them: a Tasks-tab poll in flight when the server died waited out its
own 30s timeout and reported a timeout for what was a crash, on a
connection that had already dispatched `disconnect`.

Pre-existing — both the map and the crash path predate this PR. Fixing it
here because the invariant it violates is one this PR wrote down: "every
path that ends a connection has to clear that queue, announce it, and
settle each entry", in clearAndAnnouncePendingPeerRequests' doc and the
timing test's header. One channel was true of one path out of three.

Test starts a raw-wire request and fires onclose; without the call it
hangs to the vitest timeout, which is the symptom.

Checked the two siblings and left both: taskInputAbortControllers is
already unblocked via the elicitation cancel the crash path performs, and
activeToolCallAbortController rides an ordinary client.request the SDK
settles itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-31 test fixes — justify the cast, drop the no-op tick (#1797)

- The new test's `as unknown as` had no justification, which AGENTS.md
  explicitly prohibits. It is unavoidable — rawWireRequest is private and
  every public route is gated on isTasksExtensionNegotiated(), which needs
  a modern handshake the fixture doesn't perform — so that reasoning is now
  written down instead of left to be re-derived.
- `await Promise.resolve()` was a no-op: rawWireRequest registers its entry
  in the promise executor, synchronously, before any suspension. Dropped,
  which also makes the test pin that ordering rather than paper over it —
  and stops the third recurrence of the microtask-counting pattern this
  file's own helper doc argues against.
- Moved the test into the teardown group and extended the header's teardown
  paragraph to cover the outbound direction, which it framed entirely as
  inbound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-32 — separate the outbound teardown claim from the inbound one (#1797)

My round-31 header edit spliced the outbound raw-wire passage into the
middle of the inbound claim, so "There are three: a failed connect(), a
mid-session transport close, and an explicit disconnect()" ended up reading
as scoping the outbound settle — which is two by design, since nothing
populates the map before the handshake. Round 31 established that scoping
so the code wouldn't imply an impossible state; the prose then un-scoped
it. Split into its own paragraph with its own scope clause, and reflowed
both in one pass rather than nudging breaks.

Also raced the raw-wire assertion, for the reason the file's own wait
helper documents: without the teardown rejection the test waited out the
request timeout and reported a bare vitest timeout. It now fails with
"raw-wire request not settled by teardown" — verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-33 — extract the timeout race, mark the failure-path promise handled (#1797)

Three copies of "reject after 1s so a regression fails where it happened"
had accumulated, each re-explaining itself. Extracted `withTimeout`, used
by the enqueue wait and the raw-wire assertion; injectRequest keeps its own
since its timeout also deletes the waiter entry.

On the failing path the raw-wire test left the production 30s timer armed,
so a regressed run got the intended failure plus a stray unhandled
rejection up to 30s later — the armed-timer class the file's own comment
names, on the failure path. `settled` is marked handled before the race.
Verified: with the fix removed the run fails with "raw-wire request not
settled by teardown" and no unhandled rejection.

Recorded why disconnect() drains the SDK's in-flight requests but rejects
the raw-wire map outright: the user asked to disconnect, and a Tasks poll
is not worth delaying that for. Pre-existing and deliberate, now written
down where the asymmetry is visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-34 — drop a no-op guard and the false mechanism it asserted (#1797)

Round 33 claimed the raw-wire test could leave `settled` rejecting
unhandled on a regression run, and round 33's fix encoded that as
`void settled.catch(() => {})` with a comment explaining it. Both were
wrong: `Promise.race` attaches a rejection handler to every input, so
`withTimeout(settled, …)` on the next line already marks it handled.
Verified with a standalone script — no unhandledRejection fires, and the
pre-fix version was equally safe.

Removed the line and the comment. The comment mattered more than the line:
it told the next reader that `Promise.race` does not mark its losers
handled, which is false and would cost them elsewhere.

Kept the true half by setting `timeout: 50` on that test's client, so a
regression leaves no 30s timer armed and fails with `Raw request
"tasks/get" timed out after 50 ms` — more self-describing than the raced
generic message. Verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-35 — the drain comment described a trade-off nobody made (#1797)

Last round's comment said raw-wire requests "get no share of the drain
above: the user asked to disconnect, and a Tasks poll is not worth delaying
that for" — framing a decision. Verified it isn't one: `safeDisconnectTimeout`
defaults to 0 and the only caller anywhere that passes a value is a single
test, so nothing is drained in production; and the drain polls the SDK's
`_responseHandlers`, which never holds raw-wire ids, so those were never
eligible regardless.

Reworded to what is true. The framing came from my own round-33 reply,
which described the drain as running on every disconnect — a comment
faithfully encoding a half-read mechanism, which is the same failure as the
`Promise.race` one it followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-36 — the race comment outlived the mechanism its own commit replaced (#1797)

`timeout: 50` made the request settle in ~50ms, so the 1s race can never
win and a regression fails on the assertion (`timed out after 50 ms` vs the
expected /Connection closed/) — which the commit message said, while the
comment beside it still credited the race. Reworded: the short timeout is
what fails fast and names itself, the race is the backstop for a promise
that never settles at all. Race kept; it covers the case where the
request's own timer is broken too.

Third comment in three rounds encoding a mechanism the same commit changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-37 — scope the timeout comment, align the bypass guards (#1797)

`timeout: 50` is the client-wide request timeout, not a bound on the
raw-wire request; it is inert in that test only because the test issues no
SDK request after connect. Noted, so someone adding one later knows it
would inherit a 50ms budget rather than reading the comment and looking
elsewhere.

The two installReceiverTaskResponseBypass guards read `this.receiverTasks`
while the five gates around them read the advertisement. Installing the
bypass mutates the Protocol's _requestHandlers map, so it is registration —
same as the setRequestHandler it follows. Equivalent today; aligned so the
convention is uniform rather than something the next reader has to
re-derive per site. The genuinely-runtime `receiverTasks` reads are left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-38 — one predicate for the whole bypass mechanism (#1797)

Round 37 moved the bypass *install* guard to the advertisement and left the
three branches it controls on the raw option, splitting one immutable fact
across two sources. The dead direction is the harmful one: if the tasks
advertisement ever acquires a condition — the exact edit that produced the
elicitation wedge — then advertised goes false while receiverTasks stays
true, the bypass is not installed, and the handler still returns
`{ task }` into the SDK's validating wrapper, which rejects it. That is the
breakage the bypass exists to prevent, and before round 37 it could not
happen because install and branch read the same field.

All three now read tasksCapabilityAdvertised. `receiverTasks` is left where
it belongs: assigned once, and driving the advertisement.

Also noted that the wrapper's own check is redundant with its install guard
and kept deliberately, since it must agree with the handler branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): don't carry receiver tasks into the next session (#1797)

Round-39 review found the last member of the enumeration rounds 12-14 and
31 built: receiverTaskRecords was cleared only by disconnect(). The crash
path doesn't clear it, the connect() failure catch doesn't, and connect()
never reset it — so a record created in one session survived into the next
connect() on the same instance, and `tasks/list` is answered from that map.
A new server would be told about a task it never created.

Reachable in web (the only client with receiverTasks): a task-augmented
sampling/createMessage arriving in the handshake window creates a record;
if that connect then fails with a 401, App.tsx retries connect() on the
same instance.

Cleared at the top of connect() rather than only on the crash path — the
auth-recovery retry means ending a session isn't the only way a new one
begins, and starting clean covers every route in. disconnect() keeps its
clear via the same extracted helper.

Pre-existing — the crash path never cleared these — but the handshake
window this PR opened is what lets a receiver task exist before connect
resolves, and this was the one member left outside the invariant the PR
documented. Test verified load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): reset all session-scoped state on connect, not just receiver tasks (#1797)

Round-40 review: receiverTaskRecords was one of five collections cleared
only by disconnect(). Two of the other four have real symptoms on the same
crash-then-reconnect path:

- subscribedResources — the modern subscribeToResource early-returns on a
  uri already in the set, so after a reconnect the user's Subscribe click
  silently sends nothing to the new server while the UI keeps listing it.
- cancelledTaskIds — a leftover id mislabels a *new* task sharing it as
  cancelled rather than failed, and server task ids are often short.

taskInputAbortControllers leaked un-aborted controllers.

modernLogLevel had the opposite asymmetry: disconnect() cleared it so it
couldn't leak (#1629), but nothing restored it, so a reconnect after an
explicit disconnect silently lost the user's configured level while a
reconnect after a crash kept a mid-session override. It is now recomputed
from serverSettings the way the constructor does, so both routes agree.

clearReceiverTasks became resetSessionState, called from the same place.

Tests cover the two symptomatic siblings and now assert receiver tasks
through the `tasks/list` handler — what a server actually sees, and the
symptom itself — which also removed the unjustified `as unknown as` the
previous commit added. The remaining cast is justified in place, since
cancelledTaskIds has no public reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(web): re-seed the Logs control when the client re-seeds its own level (#1797)

Round-41 review caught a consequence of the last commit. `resetSessionState`
restores the client's modern log level from the server setting at connect;
`resetSessionScopedUiState` still blanked the web control to null on every
disconnect. On the one path that reconnects the *same* instance —
auth recovery — the control then read Off while every modern request carried
`_meta` logLevel "debug", the default. A tool whose job is showing what you
send should not disagree with itself about what it sent.

The control now re-seeds from the active server's settings exactly as
setupClientForServer does, instead of blanking.

Also from review:
- Added the tests the restore never had, covering both halves: a mid-session
  override does not survive a reconnect, and a disconnect no longer drops the
  configured level (the #1629 half, and the actual behaviour change).
- resetSessionState's doc claimed "every piece of state scoped to one
  connection", which invites assuming membership rather than checking it.
  Now states the rule: state a new session would misread; what only needs
  settling on the way out stays with the teardown paths.
- Header: session-scoping paragraph moved below the outbound one to match
  test order, and it now names all five members rather than three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* refactor(core): one derivation for the modern log level (#1797)

Round-42 review: the configured level was computed independently in four
places — twice in InspectorClient (constructor, resetSessionState) and twice
in App.tsx (the seed and the re-seed). The client and the Logs control
agreed only because four sites happened to compute the same expression, and
any drift reproduces exactly the symptom the previous commit fixed: the
control reading one level while every modern request stamps another.

Extracted resolveModernLogLevel into core/mcp/types.ts beside
DEFAULT_MODERN_LOG_LEVEL; all four call sites use it, with the web mapping
undefined→null at its own boundary (display, not a second derivation).

Also from review:
- disconnect()'s comment still credited itself with preventing the #1629
  leak, which resetSessionState now prevents. It says what the line does
  now — read "not opted in" while disconnected — and notes the web control
  deliberately shows the configured level in that window.
- resetSessionState's doc claimed an un-aborted taskInputAbortControllers
  entry "leaks a paused poll loop". Both registration sites release in a
  finally, so nothing leaks permanently; the abort closes the window
  between a crash and the unwind. Softened, and it now has the test it
  lacked — the only member of the five without one.
- Noted in App.tsx that the re-seed depends on activeServerIdRef still
  holding the outgoing id (it syncs in a passive effect), so an eager clear
  would silently drop it to the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): reset the subscription stream state with the set it derives from (#1797)

Round-43 review: resetSessionState cleared subscribedResources but left
modernStreamState, so a crash-then-reconnect on the same instance held an
empty set alongside `{ active: true }` — a combination the rest of the file
treats as impossible, since onModernSubscriptionClosed and
onModernReconnectFailed both compute `active` from `subscribedResources.size
> 0`, and the comment at :4712 asserts a terminal drop clears both. No
symptom today only because the UI mirror resets itself independently, in
another file — which is the arrangement this PR has spent its rounds
removing. Clearing it here also announces the reset; that set's every other
mutation dispatches.

Also from review:
- The web re-seed treated "no server found" as the default level rather
  than Off, because resolveModernLogLevel returns the default for absent
  *settings*. Reachable via a second disconnect after a crash, and via
  removing the active server. Now distinguishes the two.
- Converged the two injectRequest copies: withTimeout takes an optional
  onTimeout, so both drop their waiter on the timeout path. The newer copy
  had lost exactly the cleanup that was round 33's reason for leaving the
  original alone.

Test seeds the stream state a live subscription would have left; verified
load-bearing (the earlier version passed either way, since the fixture
never activates a stream).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(web,core): correct the re-seed branch; share the subscription-stream reset (#1797)

Round-44 review. My round-43 fix traded one wrong branch for another: it
read `?.settings`, which is undefined both when no server matched *and*
when the server has no settings node — and the second is the common case
(a hand-written mcp.json entry never opened in Server Settings). Those
servers showed Debug while connected and Off after disconnect, which is
exactly the client/UI disagreement rounds 41-42 removed, with no
modernLogLevelChange event to reconcile it. Now branches on the server.

Worth noting no test would have caught it: App.tsx is a documented
exclusion from the coverage gate.

Also from review:
- resetSessionState cleared subscribedResources without announcing it,
  two lines below a comment arguing that every other mutation of that set
  dispatches. And it took two statements of a six-statement cluster
  disconnect() clears together — leaving modernReconnectAttempts (a new
  session's first drop would back off as if it were the old one's nth),
  modernListenGeneration (the bump that makes an in-flight re-listen bail),
  and a stale modernSubscription. One resetSubscriptionStream helper now
  serves both, announcing both axes; disconnect() keeps only the live
  stream's best-effort close. That also removed the file's one assignment
  to modernStreamState that bypassed its setter.
- The stream-state test pinned the value, not the dispatch — the half the
  UI actually tracks. Now asserts both; verified against a bare field
  assignment.
- onModernSubscriptionClosed's comment claimed the disconnect reset always
  clears the set; on a crash it doesn't until the next connect. Says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-45 — reattach the orphaned doc, order the announcements (#1797)

Extracting resetSubscriptionStream last round inserted it *and its doc*
between resetSessionState's doc and resetSessionState — so two doc blocks
stacked, the new helper took the nearest, and the 23-line block explaining
each member's symptom attached to nothing. Dead text where it sat, and the
most load-bearing comment in the batch. Moved, not reworded.

That is round 32's finding in production code, from the same cause:
inserting anything between a doc block and its method is a move that needs
checking, prose or code.

Also from review:
- resetSubscriptionStream announced the cleared set before updating the
  stream state derived from it, so a listener in between saw an empty set
  with an `active` stream — the combination the helper exists to prevent,
  exposed by its own dispatches. Both fields move first, then both are
  announced. The onModernSubscriptionClosed comment now says that window
  is never observable, which is newly true.
- The test header called the log level "cleared"; it is re-seeded, which is
  the #1629 half of the test twenty lines below. Says "reset", with the
  distinction spelled out, and the paragraph reflowed in one pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* fix(core): close the listen stream the connect-path reset drops (#1797)

Round-46 review: resetSubscriptionStream's doc claimed disconnect() is the
only caller with a stream worth closing, and the helper did a bare
`modernSubscription = null`. Not guaranteed on the connect path — an
`onerror` without an `onclose` leaves the transport up, connect() reuses
it, and the reset then drops the last reference to a stream still open on
the server. Nothing can close it afterwards: the `closed` handler bails on
the bumped generation, and `closed` never resolves for a live stream.
Before the extraction the surviving reference was eventually closed by
refreshModernSubscription as `previous`, so this was newly unclosable.

The helper now closes it itself, best-effort, after the dispatches so the
round-45 ordering is unaffected; disconnect() drops its two hand-rolled
lines.

Also from review:
- The session test pinned the subscription set's value but not its
  dispatch — the half the UI tracks, and the gap round 43 closed for the
  stream axis one line away. Both now asserted; verified load-bearing.
- resetSessionState's doc said disconnect() "clears all of this", which is
  true by duplication for two of the five members; it now says a sixth has
  to be added in both places. And "the teardown paths" handle the in-flight
  tool call only on the disconnect route — on a crash it is callTool's own
  finally. Named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-47 — fix the doc's count, catch the closed-promise chain (#1797)

- resetSessionState's doc said "two members through the same helpers, the
  other two hand-rolled" for a body with five. It is two and three — and
  the member the enumeration dropped is the one that is *paired* rather
  than duplicated: modernLogLevel is re-derived here and blanked in
  disconnect(), deliberately. Both corrected.
- `subscription.closed.then(...)` had no rejection handler, unlike every
  other promise around that stream. Newly reachable: before the last
  commit a connect-path reset dropped the reference with `closed`
  permanently pending, so the chain never settled at all; closing the
  stream is what makes it settle. Added, with the Node-terminates reasoning
  the file already applies to payloadPromise.
- The listen-stream test exercised transport reuse without asserting it —
  the half that makes "still open on the server" true. Counts factory calls
  now, so the premise fails loudly if a future change recreates the
  transport rather than silently ceasing to apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-48 — scope the catch, guard the close, index the test (#1797)

- The .catch comment claimed the connect-path close made a *rejecting*
  `closed` reachable. It doesn't: closing resolves it. What that close newly
  reaches is the handler running at all, where the reference used to be
  dropped with `closed` pending forever. And chaining after `.then` also
  swallowed handler throws — silently abandoning a re-listen on the path
  whose job is recovering a dropped stream. Now the second argument to
  `.then`, scoped to the rejection, with the comment saying which half is
  which.
- `closing?.close()` reaches into third-party code from `disconnect()`'s
  straight-line teardown, none of which is inside a `try` — a synchronous
  throw would skip the raw-wire rejects, the task aborts, the receiver-task
  clear and nine dispatches, silently, since most callers catch a
  disconnect(). Best-effort now against both failure modes.
- Header indexes the listen-stream test: same category, but a release
  obligation rather than a misread one.
- Its comment described the generation guard, which the stub can't exercise;
  marked as production behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr

* review: round-49 — pin the best-effort close, unorphan the doc (#1797)

Round 48 made `resetSubscriptionStream`'s close best-effort against both a
rejected promise and a synchronous throw, but nothing exercised either arm:
both `modernSubscription` stubs in the tree resolve. On the `disconnect()`
path an escaping failure would silently skip the raw-wire rejects, the paused
task-input aborts, the tool-call abort and `clearReceiverTasks()` — the
findings rounds 13 and 30 closed, re-opened at once with a green suite. Two
table-driven cases now seed a `close()` that throws and one that rejects,
and assert teardown continued past it.

Also: the `.then` comment gave opposite verdicts on one mechanism (an
unhandled rejection is named unacceptable, then chosen for a handler throw) —
reworded to say the handler cannot throw today and why the shape is
defensive; and `refreshModernSubscription`'s doc block, orphaned two methods
up since before this branch, moved onto the method it describes.

Closes #1797

* review: round-50 — sweep the queues start-clean, unify the close (#1797)

`resetSessionState`'s doc excluded the peer-request queues and the raw-wire
map because "the teardown paths handle them" — true of the paths that run.
An `onerror` without an `onclose` runs none of them: it flips status to
"error" and leaves `baseTransport` cached, so a `connect()` on the same
instance reuses a live transport. That is the route the round-46 stream close
was built on, and it strands these two the same way — the web pending-request
modal is derived from the queue length with no status gate, so it outlives
the session, and answering it writes a response for the old request id onto
the new connection. Both are now swept start-clean beside the reset (both
helpers are idempotent, so the routes that already ran them are unaffected),
the end-clean clears stay put for the `disconnect`-event consumer, and both
docs say which is which.

Also folds the three subscription `close()` sites onto one
`closeSubscriptionBestEffort` helper, so all three absorb a synchronous throw
as well as a rejection — at `refreshModernSubscription`'s first site the
reference is already dropped, so a throw there both leaks a live stream and
aborts the refresh before its replacement is opened.

Closes #1797

* review: round-51 — pin the sweep's ordering, name the category (#1797)

The connect-start sweep must sit after `resetSessionState()`, and the two
calls read as independent. Cancelling a task-augmented peer request settles
it synchronously into the record callback, which ends in `upsertReceiverTask`
— a no-op only because `clearReceiverTasks()` just emptied the map. Hoisted,
it would emit a `notifications/tasks/status` for the outgoing session's task
onto the transport the connect is about to reuse. Stated at the call and
pinned by a test (which fails on the hoist).

Adding that third caller also made five docs stale, all describing the old
set by count or as "teardown" — the class round 23 diagnosed. All five now
name the category: every route out, plus the top of `connect()` as a backstop
for the one route in that settles nothing.

And the site where round 50's fold actually changed behaviour —
`refreshModernSubscription`'s first close, where the reference is already
dropped and a `.catch()` never caught a synchronous throw — is now tested
from the public surface (a re-list over a poisoned stream), table-driven over
both arms. Verified to fail on the pre-fold form.

Closes #1797

* review: round-52 — say what the sweep prevents, scope the close doc (#1797)

The sweep's comment named as its harm the exact write the sweep performs: on
this route the SDK's transport is still live, so cancelling a queued peer
request emits a response for the old id immediately — which is right, and is
the settle-don't-discard rule, at the best available moment. As written it
read as an argument against emitting the frame, inviting the clear-without-
settling regression rounds 24–25 removed. It now names the real hazard: a
*user*-authored answer landing arbitrarily later, past the re-handshake.

`closeSubscriptionBestEffort`'s "at each site" held at two of three — the
superseded-generation discard stored nothing and has nothing following it,
so its escaping failure would only reject an already-superseded
`subscribeToResource`. Scoped, still wrapped.

Also moves the `disconnect()`-side close pair next to its `connect()`-side
twin, and states which axis the file's ordering follows, since route and
category both describe it and cut differently.

Closes #1797

* review: round-53 — give each close site its real harm, pin it (#1797)

The doc grouped `resetSubscriptionStream`'s close with the "takes the
caller's remaining work with it" sites. It can't: the call is `void`-ed onto
an `async` helper, so even a synchronous `close()` throw becomes a dropped
rejection — `disconnect()`'s teardown runs regardless. The harm there is an
*unhandled* rejection, which this PR already established (the payloadPromise
fix) is the process-fatal class in Node — so the one site with the severe
failure mode was documented as having the mild one. Each site now carries its
own consequence, and the local comment no longer gives two reasons that
cancel each other.

The test had the same gap: both its assertions hold with the helper's
try/catch deleted. It now also asserts no unhandled rejection fired, which
does fail without the guard (verified on both arms); the teardown assertions
stay, since they still catch unwrapping the call to a bare `close()`.

Also names the two back-references the round-52 move left positional, and
softens the "on the wire until dropCachedTransport()" claim — that drop is
conditional and never runs on stdio.

Closes #1797

* review: round-54 — the mildest site isn't mild from every caller (#1797)

`refreshModernSubscription` has three callers, and the superseded-generation
discard's failure reaches all of them. From `unsubscribeFromResource` — which
deliberately keeps the removal when a re-listen fails — it surfaces "Failed
to unsubscribe" for an unsubscribe that actually stuck, which is worse than
the consequence the doc assigned to the site it called mildest. The re-listen
bullet had the mirror gap: on the reconnect caller the escape is absorbed
into the backoff run, not a set left with no stream. Both now name their
callers, and the generation-bump claim is softened to what it proves (a newer
refresh *started*).

Test: the `unhandledRejection` listener is process-wide, so the assertion now
filters to this fixture's own reason rather than failing on a rejection some
other test left pending; and it is registered inside the `try` whose `finally`
removes it, so a throw in the setup can't leak it into the rest of the file.
Re-verified the filtered form still fails on both arms with the helper's
try/catch removed.

Closes #1797

* fix(core): don't roll back a subscribe a newer refresh superseded (#1797)

`subscribeToResource`'s rollback assumes what its comment says — that the
server filter is unchanged — which is exactly what supersession falsifies.
The newer refresh built its filter from the set *including* this URI, so on
success the server honors it; rolling back leaves the set missing a URI the
live stream carries (the UI showing it unsubscribed while its
resources/updated keep arriving) and, if it was the only one, an empty set
with an active stream — the combination `resetSubscriptionStream` exists to
prevent, reached from the other end.

Reachable without any close() failure: a rejecting `listen()` propagates the
same way, which a user subscribing while the reconnect timer re-lists — i.e.
exactly when the server is flaky — can produce. The rollback is now gated on
the generation not having advanced past this call's own bump; the error still
reaches the caller either way. Test drives two overlapping subscribes with
the first `listen()` rejecting, and fails without the gate.

The doc bullet that called this the mild site is corrected with it: both
user-initiated callers report a failure that did not happen, in mirrored
ways. Also gives the two close-failure arms distinct reasons so the
unhandled-rejection filter can exclude the sibling arm as well as a foreign
rejection.

Closes #1797

* fix(core): reconcile the stream badge when a re-listen this call owned fails

The supersession gate left the both-fail case stuck: two overlapping
subscribes whose listens both reject leave the superseded URI in the set (its
rollback correctly skipped) while the state keeps the optimistic
"connecting" — no stream, no reconnect armed (neither onModernSubscriptionClosed
nor onModernReconnectFailed ran), so the badge never changes and no
resources/updated ever arrive. That is the caveat this file already wrote
down for the neighbouring case — the bump proves a newer refresh *started* —
applied to the gate itself.

Fixed in the branch that knows the answer: the call that still owns the
re-listen reconciles the state instead of only handling an emptied set,
reusing the "ended" badge onModernSubscriptionClosed already reports for
"stream gone, URIs still subscribed". unsubscribeFromResource gets the same
treatment — it rolls nothing back by design, but its failed re-listen left
the badge reading Listening over no stream.

Also: the gate's comment named one of the two things that bump the
generation (resetSubscriptionStream also does, from disconnect and the
start-clean reset) and cross-referenced the wrong invariant; both corrected,
and `<=` tightened to `===`, which is what the monotonic synchronous bump
actually guarantees.

Closes #1797

* fix(core): order the subscription writes, retry instead of stranding

Two sites emptied the subscription set and announced it before the stream
state caught up — the last-URI rollback in subscribeToResource's catch, and
(pre-existing) the ordinary last-URI unsubscribe, which dispatches a
round-trip before the re-listen sets INACTIVE. Both exposed an empty set with
an active stream: the pair resetSubscriptionStream orders its own writes to
prevent, and which three comments in this branch now cite. State before the
announce at both, asserted from the consumer's side.

And the reconcile added last round settled for an honest but dead badge: it
was the only route to "stream gone, URIs live" that had made no attempt at
all, over subscriptions the server may never have honored, recoverable only
by an Unsubscribe/Subscribe toggle (a fresh Subscribe early-returns). It now
hands them to the reconnect machinery, which already fits — base delay after
a user-initiated refresh, bails on a terminal status or an emptied set, and
lands on the same "ended" badge past the cap. That also removes the
provably-dead null check the reconcile carried.

Closes #1797

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:32:23 -04:00

5442 lines
218 KiB
TypeScript

import { Client } from "@modelcontextprotocol/client";
import type {
MCPServerConfig,
StderrLogEntry,
ConnectionStatus,
MessageEntry,
MessageOrigin,
FetchRequestEntry,
FetchRequestEntryBase,
InspectorServerSettings,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
PromptGetInvocation,
ToolCallInvocation,
AppRendererClient,
InspectorClientOptions,
PendingRequestOrigin,
ResourceSubscriptionStreamState,
ExcludedTool,
} from "./types.js";
import { scanXMcpHeaderDeclarations } from "../json/xMcpHeader.js";
// Re-export so v1.5 tests that do `import { InspectorClientOptions } from
// "@inspector/core/mcp/inspectorClient.js"` keep resolving.
export type {
InspectorClientOptions,
InspectorClientEnvironment,
CreateTransport,
CreateTransportOptions,
CreateTransportResult,
AppRendererClient,
} from "./types.js";
import { getServerType as getServerTypeFromConfig } from "./config.js";
import {
INACTIVE_SUBSCRIPTION_STREAM_STATE,
isTerminalStatus,
resolveModernLogLevel,
} from "./types.js";
import { cleanRoots } from "./serverList.js";
// Fallback client identity, used ONLY when a caller doesn't pass
// `clientIdentity`. Real clients supply their own: the Node clients (CLI, TUI)
// read the single-source version from the root package.json via
// `readInspectorVersion()`, and the web browser — which can't read the
// filesystem — will pass a version sourced from `GET /api/config` (see #1639).
// This stays a neutral placeholder rather than a hardcoded release number that
// would silently drift out of sync with the root package.json version.
const corePackageJson = {
name: "mcp-inspector",
version: "0.0.0",
} as const;
import type {
CreateTransport,
CreateTransportOptions,
ServerType,
} from "./types.js";
import {
MessageTrackingTransport,
type MessageTrackingCallbacks,
} from "./messageTrackingTransport.js";
import type {
CallToolRequest,
JSONRPCRequest,
JSONRPCNotification,
JSONRPCResultResponse,
JSONRPCErrorResponse,
JSONRPCMessage,
ServerCapabilities,
ClientCapabilities,
Implementation,
LoggingLevel,
Tool,
Resource,
ResourceTemplateType as ResourceTemplate,
Prompt,
Root,
CreateMessageRequest,
CreateMessageResult,
CreateTaskResult,
ElicitRequest,
ElicitResult,
ElicitRequestURLParams,
CallToolResult,
Task,
Progress,
ProgressToken,
ListToolsRequest,
ListResourcesRequest,
ListResourceTemplatesRequest,
ListPromptsRequest,
ReadResourceRequest,
GetPromptRequest,
CompleteRequest,
ListRootsRequest,
} from "@modelcontextprotocol/client";
import type { Transport } from "@modelcontextprotocol/client";
import type {
RequestOptions,
CacheableRequestOptions,
CacheMode,
ProgressCallback,
VersionNegotiationOptions,
ProtocolEra,
DiscoverResult,
InputRequests,
InputRequiredOptions,
StandardSchemaV1,
McpSubscription,
SubscriptionFilter,
} from "@modelcontextprotocol/client";
import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
import {
isInputRequiredResult,
withInputRequired,
LOG_LEVEL_META_KEY,
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
RELATED_TASK_META_KEY,
} from "@modelcontextprotocol/client";
import {
TASKS_EXTENSION_KEY,
MODERN_TASK_HANDLE_META,
MODERN_PROTOCOL_VERSION,
ModernGetTaskResultSchema,
ModernUpdateTaskResultSchema,
ModernCancelTaskResultSchema,
normalizeModernTask,
readInputRequests,
isModernCreateTaskResult,
type ModernDetailedTask,
} from "./modernTaskSchemas.js";
import { buildClientExtensions } from "./extensions.js";
import {
EmptyResultSchema,
CallToolResultSchema,
GetPromptResultSchema,
ReadResourceResultSchema,
// Task request schemas — used for `.shape.params` in the 3-arg custom
// `setRequestHandler` form (tasks/* are excluded from v2's spec-method set).
ListTasksRequestSchema,
GetTaskRequestSchema,
GetTaskPayloadRequestSchema,
CancelTaskRequestSchema,
TaskStatusNotificationSchema,
// Task result schemas — explicit result schemas for the raw requestor-task
// requests that replace the removed `client.experimental.tasks.*` helpers.
CreateTaskResultSchema,
GetTaskResultSchema,
CancelTaskResultSchema,
ListTasksResultSchema,
// List result schemas — used by the single-page list methods below. SDK v2's
// high-level `client.listTools()` etc. auto-aggregate ALL pages (returning
// `nextCursor: undefined`), which defeats the Inspector's pagination-debugging
// purpose. Drop to raw `client.request` with these explicit schemas so each
// call fetches exactly one page and surfaces the server's `nextCursor`.
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListPromptsResultSchema,
} from "@modelcontextprotocol/core";
import type { ClientResult } from "@modelcontextprotocol/client";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
import { validateToolOutput } from "./toolOutputValidation.js";
import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js";
import {
type JsonValue,
convertToolParameters,
convertPromptArguments,
} from "../json/jsonUtils.js";
import { UriTemplate } from "@modelcontextprotocol/client";
import {
InspectorClientEventTarget,
type TaskWithOptionalCreatedAt,
} from "./inspectorClientEventTarget.js";
import { SamplingCreateMessage } from "./samplingCreateMessage.js";
import { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
import {
getUrlElicitationsFromError,
UrlElicitationLoopError,
} from "./urlElicitation.js";
import { ToolCallCancelledError } from "./toolCallCancelledError.js";
import type {
OAuthConnectionState,
OAuthFlowState,
OAuthStep,
} from "../auth/types.js";
import {
AuthRecoveryRequiredError,
EMA_STEP_UP_PENDING_URL,
isAuthChallengeError,
isConnectAuthRecoveryError,
parseAuthChallengeFromError,
type AuthChallenge,
type AuthChallengeOutcome,
type HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js";
import { RemoteClientTransport } from "./remote/remoteClientTransport.js";
/** Internal record for a receiver task (server polls us for status/result). */
interface ReceiverTaskRecord {
task: Task;
payloadPromise: Promise<ClientResult>;
resolvePayload: (payload: ClientResult) => void;
rejectPayload: (reason?: unknown) => void;
cleanupTimeoutId?: ReturnType<typeof setTimeout>;
}
/**
* Cap on how many times a single `callTool` will surface URL elicitations and
* retry after a `-32042` (UrlElicitationRequired) response. A spec-compliant
* flow resolves in one round; the bound only guards against a server that keeps
* returning the error.
*/
const MAX_URL_ELICITATION_RETRIES = 5;
/**
* Error used to reject a pending sampling/elicitation request when the tool
* call driving its MRTR round is aborted (e.g. the user hits Cancel). Its
* message is not surfaced directly — `callToolWithRetries` maps the abort to a
* {@link ToolCallCancelledError} by inspecting the controller's reason — but a
* concrete error is needed to reject the awaiting driver promise.
*/
function createPendingAbortError(): Error {
return new Error("Pending request aborted");
}
/**
* The abort reason used by `cancelToolCall()`. It rides along on the
* `notifications/cancelled` sent to the server and lets `callToolWithRetries`
* tell a deliberate user cancel apart from other aborts of the same controller
* (e.g. a disconnect, which aborts with a different reason and should surface as
* an ordinary error, not a "Tool call cancelled" — #1458).
*/
const TOOL_CALL_CANCELLED_REASON = "Tool call cancelled by user";
/**
* Fallback poll cadence (ms) for {@link InspectorClient.pollTaskToolCall} when a
* task does not advertise its own `pollInterval`. Replaces the cadence the
* removed SDK `experimental.tasks.callToolStream` helper managed internally.
*/
const DEFAULT_TASK_POLL_INTERVAL_MS = 500;
/**
* Close a modern listen stream best-effort, absorbing both failure modes a
* third-party `close()` can produce: a rejected promise and a synchronous
* throw. All three stream closes go through here, and every one of them has
* already dropped its reference to the stream — so in each case an escaping
* failure abandons a stream that may still be open on the server. What it does
* *besides* that differs per site, because the shape of the call does:
*
* - `resetSubscriptionStream` — fire-and-forget (`void`), and the last statement
* of the method. Because this helper is `async`, even a synchronous throw
* becomes a rejection of the promise it returns, which the `void` then drops:
* the caller is unaffected, and the harm is an *unhandled rejection* — fatal
* to a Node process by default, a console error in the browser. That, not
* skipped teardown, is what the wrapping buys at this site.
* - `refreshModernSubscription`'s re-listen close — awaited, with the
* replacement `listen()` after it, so an escaping failure skips the re-listen
* and leaves a non-empty subscription set with no stream. On the reconnect
* caller it is instead absorbed into the backoff run and retried.
* - the superseded-generation discard — awaited, but `return` follows, so
* nothing local is skipped; the failure only propagates out of
* `refreshModernSubscription` to whichever caller a newer refresh had already
* superseded. Self-healing on the reconnect path; on the two user-initiated
* paths it is a report of a failure that did not happen — for
* `unsubscribeFromResource`, whose removal is kept regardless, a "Failed to
* unsubscribe" for an unsubscribe that stuck. Both of those gate their
* failure handling on still owning the re-listen, so a superseded call
* reports its error without editing a filter or a badge that is no longer
* its own (see `subscribeToResource`'s catch for the reasoning).
*
* Note what the generation bump proves in that last case is that a newer
* refresh had *started*, not that it succeeded — it may yet fail at its own
* `listen()`, making an escaping failure here a second one rather than a
* redundant one.
*
* Wrapped identically all the same, so the rule is one rule (#1630, #1797).
*/
async function closeSubscriptionBestEffort(
subscription: Pick<McpSubscription, "close">,
): Promise<void> {
try {
await subscription.close();
} catch {
// Best-effort: there is nothing to do about a stream that won't close.
}
}
/**
* Extract the method literal from an MCP notification Zod schema (e.g.
* `ToolListChangedNotificationSchema`), or `undefined` if the shape isn't
* recognized. Used by the App-renderer client proxy to translate the SDK-v1
* schema-first `setNotificationHandler` API — which `@modelcontextprotocol/ext-apps`
* still uses — into SDK v2's method-string form. Reads the `method` literal off
* the notification schema's `shape` (the shape both the v1 SDK and v2 core
* schemas expose).
*/
function notificationMethodFromSchema(schema: unknown): string | undefined {
if (schema !== null && typeof schema === "object") {
const literal = (schema as { shape?: { method?: { value?: unknown } } })
.shape?.method?.value;
if (typeof literal === "string") return literal;
}
return undefined;
}
/**
* The descriptor for a single tools/call, threaded through the retry loop and
* each attempt. Bundled into one object so `callToolWithRetries`/`attemptToolCall`
* don't take a long, transposition-prone positional parameter list.
*/
interface ToolCallRequest {
tool: Tool;
args: Record<string, JsonValue>;
generalMetadata?: Record<string, string>;
toolSpecificMetadata?: Record<string, string>;
taskOptions?: { ttl?: number };
options?: { skipOutputValidation?: boolean };
}
// Backoff for reconnect-by-re-listen on the modern `subscriptions/listen` stream
// (#1630). A `"remote"` drop schedules a re-listen after a capped exponential
// delay (based on the count of *consecutive failed* re-lists) so a flapping
// server can't spin a tight zero-delay loop; a successful acknowledgement resets
// the count, and past the cap we give up (mark the stream ended) rather than
// retry a persistently-failing re-list forever.
const MODERN_RECONNECT_BASE_MS = 500;
const MODERN_RECONNECT_MAX_MS = 15_000;
const MODERN_RECONNECT_MAX_ATTEMPTS = 8;
/**
* InspectorClient wraps an MCP Client and provides:
* - Message tracking and storage
* - Stderr log tracking and storage (for stdio transports)
* - EventTarget interface for React hooks (cross-platform: works in browser and Node.js)
* - Access to client functionality (prompts, resources, tools)
*/
export class InspectorClient extends InspectorClientEventTarget {
/**
* Upper bound on MRTR (`input_required`) rounds for a single logical request
* before {@link requestWithInputRequired} gives up. We drive the loop
* ourselves (`inputRequired: { autoFulfill: false }`), so this is the manual
* counterpart to the SDK auto-driver's default `maxRounds` (10) and guards
* against a server that keeps returning `input_required` forever.
*/
private static readonly MRTR_MAX_ROUNDS = 10;
private client: Client | null = null;
private appRendererClientProxy: AppRendererClient | null = null;
// Lazily-built validator used only on the skipOutputValidation path to detect
// (non-fatally) when a delivered result violates the tool's outputSchema.
private outputValidator: AjvJsonSchemaValidator | null = null;
private transport: Transport | MessageTrackingTransport | null = null;
private baseTransport: Transport | null = null;
/** True when the cached transport was built with an OAuth authProvider attached. */
private transportHasAuthProvider = false;
/** Dedupes concurrent ambient auth challenges (reason + scopes). */
private ambientAuthChallengeInFlight = new Map<string, Promise<void>>();
private pipeStderr: boolean;
private initialLoggingLevel?: LoggingLevel;
// Modern-era per-request log level (#1629). On 2026-07-28 servers
// `logging/setLevel` is gone; the client opts into logs per request via the
// `io.modelcontextprotocol/logLevel` `_meta` key, and the SDK does not attach
// it automatically. When set, `mergeMeta` stamps this level on every outgoing
// request so server logs arrive on each request's stream; `undefined` means
// "don't opt in" (logs stay silently absent). Only honored on the modern era.
private modernLogLevel?: LoggingLevel;
private readonly sample: boolean;
private readonly elicit: boolean | { form?: boolean; url?: boolean };
private progress: boolean;
private resetTimeoutOnProgress: boolean;
private requestTimeout: number | undefined;
private defaultMetadata: Record<string, string> | undefined;
private serverSettings: InspectorServerSettings | undefined;
private versionNegotiation: VersionNegotiationOptions;
private status: ConnectionStatus = "disconnected";
// True only while an explicit disconnect() owns the teardown. close() can
// trigger the transport's onclose synchronously, so this lets onclose defer
// the canonical status set + `disconnect` event to disconnect() and fire it
// exactly once (see onclose / disconnect()).
private disconnecting = false;
// Server data (resources, resourceTemplates, prompts are in state managers)
private capabilities?: ServerCapabilities;
private serverInfo?: Implementation;
private instructions?: string;
private protocolVersion?: string;
// Era model (SEP §7.8). Populated after connect from the SDK Client's
// negotiation accessors. `protocolEra` is the negotiated era ("legacy" |
// "modern"); `discoverResult` is the `server/discover` payload on a
// probed/pinned connection (undefined on a plain legacy connect).
private protocolEra?: ProtocolEra;
private discoverResult?: DiscoverResult;
// Tools the SDK excludes from `tools/list` for invalid `x-mcp-header`
// annotations (SEP-2243), recomputed on every aggregate tools refresh and
// surfaced so the Tools tab can show why a tool vanished (#1632).
private excludedTools: ExcludedTool[] = [];
// The capabilities this Inspector client advertises to the server during the
// initialize handshake. Built once in setupClient() and snapshotted here so
// UI surfaces (Server Info modal) can display them without poking at the
// SDK Client's private state.
private clientCapabilities: ClientCapabilities = {};
// The client identity ({name, version}) passed to the SDK Client. Reused to
// build the modern per-request envelope for raw tasks/* requests.
private clientInfo: Implementation;
// Sampling requests
private pendingSamples: SamplingCreateMessage[] = [];
// Elicitation requests
private pendingElicitations: ElicitationCreateMessage[] = [];
// Roots (undefined means roots capability not enabled, empty array means enabled but no roots)
private roots: Root[] | undefined;
/**
* Whether `capabilities.roots` was advertised at `initialize`, read off the
* capability object actually sent rather than re-derived from the constructor
* option. Fixed for the client's lifetime, because the capability is
* negotiated at construction and the SDK refuses `registerCapabilities` after
* connect.
*
* The `roots/list` registration gates on *this*, not on `this.roots`, which
* `setRoots()` can make defined later: the SDK throws "Client does not support
* roots capability" from `setRequestHandler` when the capability was never
* advertised, and that throw would land before the handshake on every
* subsequent `connect()` — wedging the client permanently (#1797).
*/
private readonly rootsCapabilityAdvertised: boolean;
/**
* Whether `capabilities.elicitation` was advertised, on the same terms as
* {@link rootsCapabilityAdvertised}. Not the same question as `this.elicit`
* being truthy: `{}` / `{ form: false, url: false }` are valid options that
* enable no mode, so nothing is advertised — and registering
* `elicitation/create` anyway throws "Client does not support elicitation
* capability" before the handshake, leaving the client unable to connect
* at all (#1797).
*/
private readonly elicitationCapabilityAdvertised: boolean;
/** As above, for `capabilities.sampling`. */
private readonly samplingCapabilityAdvertised: boolean;
/** As above, for `capabilities.tasks` (the receiver-side `tasks/*` polls). */
private readonly tasksCapabilityAdvertised: boolean;
/** As above, for `capabilities.elicitation.url` (the URL-mode completion). */
private readonly urlElicitationCapabilityAdvertised: boolean;
/**
* As above, for `capabilities.roots.listChanged` — the predicate the SDK
* asserts before letting us *send* `roots/list_changed`, which is narrower
* than the `capabilities.roots` presence it asserts before letting us
* *register* `roots/list`. They coincide today (roots are only ever
* advertised as `{ listChanged: true }`), but reading each assertion's own
* predicate is what keeps a future conditional advertisement from silently
* un-gating one of them.
*/
private readonly rootsListChangedCapabilityAdvertised: boolean;
// Content cache
// ListChanged notification configuration
private listChangedNotifications: {
tools: boolean;
resources: boolean;
prompts: boolean;
};
// Resource subscriptions. The set of subscribed URIs is the era-agnostic
// source of truth for the UI (the `resourceSubscriptionsChange` list). How a
// subscription reaches the wire forks by era: legacy sends one
// `resources/subscribe` per URI; modern (2026-07-28) has no such method — all
// subscriptions are a filter over one long-lived `subscriptions/listen` stream
// (#1630, SEP §7.4).
private subscribedResources: Set<string> = new Set();
// Modern-era listen stream backing the subscriptions above. A single
// `McpSubscription` whose filter's `resourceSubscriptions` mirrors
// `subscribedResources`; mutating the set re-lists (close old, open new), and
// an unexpected `"remote"` close re-lists (reconnect-by-re-listen — the stream
// is not resumable). `null` when no URI is subscribed or on the legacy era.
private modernSubscription: McpSubscription | null = null;
// Monotonic guard so a stale re-list/reconnect (whose `listen()` or `closed`
// resolves after a newer refresh already started) can detect it lost the race
// and bail without clobbering the current stream.
private modernListenGeneration = 0;
// Last dispatched modern stream state; `active: false` on the legacy era.
private modernStreamState: ResourceSubscriptionStreamState =
INACTIVE_SUBSCRIPTION_STREAM_STATE;
// Reconnect-by-re-listen backoff state (#1630): the count of consecutive
// *failed* re-lists (reset to 0 on any successful acknowledgement) and the
// pending re-listen timer.
private modernReconnectAttempts = 0;
private modernReconnectTimer: ReturnType<typeof setTimeout> | undefined;
// Task ids the user explicitly cancelled. A cancel makes the in-flight
// `callToolStream` reject with a generic -32603 error, which the stream's
// error path would otherwise report as a *failed* task — flashing "failed"
// in the UI until a refresh fetches the server's true "cancelled" state.
// Recording the id lets that path label the terminal task "cancelled"
// instead, so it lands in the right state immediately (#1455). Cleared on
// disconnect.
private cancelledTaskIds: Set<string> = new Set();
// Per-task abort controllers for a modern task paused at `input_required`.
// While the poll loop blocks on the pending elicitation (the modal), the tool
// call's own abort path isn't in play — so `cancelRequestorTask` aborts this
// controller to reject the pending request, close the modal, and let the poll
// observe the cancellation. Keyed by taskId; created/removed by the poll loops.
private taskInputAbortControllers = new Map<string, AbortController>();
// Pending raw-wire requests (modern tasks/* — see rawWireRequest). Keyed by a
// string JSON-RPC id we mint; the SDK Client only mints numeric ids, so ours
// never collide with (or reach) it. Resolved by the transport's
// consume-response hook and rejected on disconnect.
private pendingRawWireRequests = new Map<
string,
{
resolve: (result: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
>();
private rawWireRequestCounter = 0;
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
// it makes the SDK send a `notifications/cancelled` for that request (the MCP
// cancellation flow) and reject the pending call, which `callTool` surfaces as
// a `ToolCallCancelledError`. Undefined when no ordinary call is in flight.
// Task-augmented calls have a server-side task and are cancelled via
// `cancelRequestorTask` instead, so they don't use this (#1458).
private activeToolCallAbortController?: AbortController;
// Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us)
private readonly receiverTasks: boolean;
// Per-extension advertise overrides (#1738); undefined key falls back to the
// registry default in ADVERTISABLE_EXTENSIONS.
private readonly advertisedExtensions?: Record<string, boolean>;
private receiverTaskTtlMs: number | (() => number);
private receiverTaskRecords: Map<string, ReceiverTaskRecord> = new Map();
// OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured")
private oauthManager: OAuthManager | null = null;
private logger: InspectorLogger;
private transportClientFactory: CreateTransport;
private fetchFn?: typeof fetch;
private effectiveAuthFetch: typeof fetch;
// Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState)
private sessionId?: string;
private transportConfig: MCPServerConfig;
/** null until first transport is built; then true for in-process OAuth runners. */
private directAuthRecoveryActive: boolean | null = null;
/**
* Opt-in from {@link InspectorClientOptions.directAuthRecovery}: when true and
* the live transport is direct (not {@link RemoteClientTransport}), RPCs use
* fetch intercept + {@link withDirectAuthRecovery}.
*/
private readonly directAuthRecovery: boolean;
constructor(
transportConfig: MCPServerConfig,
options: InspectorClientOptions,
) {
super();
this.transportConfig = transportConfig;
// Extract environment components
this.transportClientFactory = options.environment.transport;
this.fetchFn = options.environment.fetch;
this.logger = options.environment.logger ?? silentLogger;
// Initialize content cache
this.pipeStderr = options.pipeStderr ?? false;
this.initialLoggingLevel = options.initialLoggingLevel;
this.sample = options.sample ?? true;
this.elicit = options.elicit ?? true;
this.receiverTasks = options.receiverTasks ?? false;
this.advertisedExtensions = options.advertisedExtensions;
this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000;
this.progress = options.progress ?? true;
this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true;
this.requestTimeout = options.timeout;
this.defaultMetadata =
options.defaultMetadata && Object.keys(options.defaultMetadata).length > 0
? options.defaultMetadata
: undefined;
this.serverSettings = options.serverSettings;
// Seed the modern per-request log level from the server setting (#1629), so
// a modern connection opts into logs by default without the user touching
// the Logs-tab control. Absence means DEFAULT_MODERN_LOG_LEVEL; `"off"`
// clears the opt-in. Only stamped on modern connections (see mergeMeta) —
// legacy uses `logging/setLevel`.
this.modernLogLevel = resolveModernLogLevel(options.serverSettings);
// Default to the legacy 2025-11-25 era when the caller doesn't pin one, per
// the SDK guidance that a debugging tool must not auto-probe (#1626).
this.versionNegotiation = options.versionNegotiation ?? { mode: "legacy" };
this.directAuthRecovery = options.directAuthRecovery ?? false;
// Only set roots if explicitly provided (even if empty array) - this enables
// roots capability. Normalized here as well as in `setRoots`, so core owns
// the invariant rather than trusting each client to clean at its call site
// (they do, and `cleanRoots` is idempotent, so this costs nothing). The
// ternary preserves the `undefined`-vs-`[]` distinction the capability
// advertisement below gates on.
this.roots =
options.roots !== undefined ? cleanRoots(options.roots) : undefined;
// Initialize listChangedNotifications config (default: all enabled)
this.listChangedNotifications = {
tools: options.listChangedNotifications?.tools ?? true,
resources: options.listChangedNotifications?.resources ?? true,
prompts: options.listChangedNotifications?.prompts ?? true,
};
// Effective auth fetch: base fetch + tracking with category 'auth'
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
this.sessionId = options.sessionId;
// Merge OAuth config with environment components; create internal OAuth manager (owns config)
if (options.oauth || options.environment.oauth) {
const oauthConfig: OAuthManagerConfig = {
// Environment components (storage, navigation, redirectUrlProvider)
...options.environment.oauth,
// Config values (clientId, clientSecret, clientMetadataUrl, scope)
...options.oauth,
};
this.oauthManager = new OAuthManager({
getServerUrl: () => this.getServerUrl(),
effectiveAuthFetch: this.effectiveAuthFetch,
getEventTarget: () => this,
onBeforeOAuthRedirect: (sessionId: string) => {
this.sessionId = sessionId;
this.saveSession();
return Promise.resolve();
},
initialConfig: oauthConfig,
enterpriseManagedAuth: options.enterpriseManagedAuth,
installEnterpriseManagedAuth: options.installEnterpriseManagedAuth,
dispatchOAuthComplete: (detail) =>
this.dispatchTypedEvent("oauthComplete", detail),
dispatchOAuthAuthorizationRequired: (detail) =>
this.dispatchTypedEvent("oauthAuthorizationRequired", detail),
dispatchOAuthError: (detail) =>
this.dispatchTypedEvent("oauthError", detail),
});
}
// Transport is created in connect() (single place for create / wrap / attach).
// Build client capabilities
const clientOptions: {
capabilities?: ClientCapabilities;
versionNegotiation?: VersionNegotiationOptions;
inputRequired?: InputRequiredOptions;
} = {
// Per-server protocol era (SEP §7.8), threaded from config via
// `eraToVersionNegotiation` and defaulted to `{ mode: "legacy" }` in the
// constructor. "legacy" keeps the wire byte-identical to a 2025 client;
// "auto"/"modern" opt into 2026-era negotiation (#1626).
versionNegotiation: this.versionNegotiation,
// Drive MRTR (SEP-2322) manually instead of letting the SDK auto-fulfil
// and hide the retry loop (#1704). Unconditional and safe on every era:
// legacy servers never return `input_required`, so this is a no-op there;
// on modern connections the three multi-round-trip methods opt in via
// `allowInputRequired` in `requestWithInputRequired`, and no other method
// can receive an `input_required` result. The negotiated era is unknown
// at construction time, so gating here is impossible anyway.
inputRequired: { autoFulfill: false },
};
const capabilities: ClientCapabilities = {};
if (this.sample) {
capabilities.sampling = {};
}
// Handle elicitation capability with mode support
if (this.elicit) {
const elicitationCap: NonNullable<ClientCapabilities["elicitation"]> = {};
if (this.elicit === true) {
// Backward compatibility: `elicit: true` means form support only
elicitationCap.form = {};
} else {
// Explicit mode configuration
if (this.elicit.form) {
elicitationCap.form = {};
}
if (this.elicit.url) {
elicitationCap.url = {};
}
}
// Only add elicitation capability if at least one mode is enabled
if (Object.keys(elicitationCap).length > 0) {
capabilities.elicitation = elicitationCap;
}
}
// Advertise roots capability if roots option was provided (even if empty array)
if (this.roots !== undefined) {
capabilities.roots = { listChanged: true };
}
// Receiver tasks: advertise so server can send task-augmented createMessage/elicit and poll us
if (this.receiverTasks) {
// `requests` declares which server→client requests we accept as tasks, so
// it must name only capabilities we actually advertised — both are decided
// above. Advertising a channel we then answer `-32601` on is the shape
// #1797 is about, and `{ receiverTasks: true, elicit: false }` would do
// exactly that.
const taskRequests: NonNullable<
NonNullable<ClientCapabilities["tasks"]>["requests"]
> = {};
if (capabilities.sampling) {
taskRequests.sampling = { createMessage: {} };
}
if (capabilities.elicitation) {
taskRequests.elicitation = { create: {} };
}
capabilities.tasks = {
list: {},
cancel: {},
...(Object.keys(taskRequests).length > 0 && { requests: taskRequests }),
};
}
// Assemble the advertised-extensions map from one builder (the single
// source of truth), instead of ad-hoc per-extension spreads. It layers the
// registry defaults (with any user overrides from `advertisedExtensions`)
// and the auth-mode-driven EMA extension. The Tasks entry defaults to
// advertised, so the map is non-empty and `capabilities.extensions` is
// always attached — the modern Tasks extension (SEP-2663) must ride every
// modern request envelope for a server to legally return a `CreateTaskResult`
// (harmless on legacy, where extensions are ignored). (#1738)
const advertisedExtensions = buildClientExtensions({
enterpriseManaged: options.oauth?.enterpriseManaged ?? false,
advertised: this.advertisedExtensions,
});
if (Object.keys(advertisedExtensions).length > 0) {
capabilities.extensions = {
...capabilities.extensions,
...advertisedExtensions,
};
}
clientOptions.capabilities = capabilities;
this.clientCapabilities = capabilities;
// Read off the built capability object rather than re-deriving from
// `options.roots`: the gate and the advertisement must agree, and two
// independent derivations of the same fact can drift (a `readonly` field is
// assignable anywhere in the constructor).
this.rootsCapabilityAdvertised = capabilities.roots !== undefined;
this.elicitationCapabilityAdvertised =
capabilities.elicitation !== undefined;
this.samplingCapabilityAdvertised = capabilities.sampling !== undefined;
this.tasksCapabilityAdvertised = capabilities.tasks !== undefined;
this.urlElicitationCapabilityAdvertised =
capabilities.elicitation?.url !== undefined;
this.rootsListChangedCapabilityAdvertised =
capabilities.roots?.listChanged === true;
this.appRendererClientProxy = null;
this.clientInfo = options.clientIdentity ?? {
name: corePackageJson.name.split("/")[1] ?? corePackageJson.name,
version: corePackageJson.version,
};
this.client = new Client(
this.clientInfo,
Object.keys(clientOptions).length > 0 ? clientOptions : undefined,
);
}
private buildEffectiveAuthFetch(): typeof fetch {
const base = this.fetchFn ?? fetch;
// Capture auth response bodies (OAuth discovery, DCR, token exchange) so
// they're inspectable in the Network tab. Token-exchange responses carry
// `access_token` / `refresh_token`; the Network UI masks those (and other
// known secret fields) behind a click-to-reveal toggle so they aren't
// surfaced at a glance during a screen-share. Masking is a display
// concern, kept in the UI layer rather than mutating the captured entry,
// so the raw body stays available for the user who explicitly reveals it.
return createFetchTracker(base, {
trackRequest: (entry) =>
this.dispatchFetchRequest({ ...entry, category: "auth" }),
updateResponseBody: (id, body) =>
this.dispatchFetchRequestBodyUpdate(id, body),
});
}
private createMessageTrackingCallbacks(): MessageTrackingCallbacks {
return {
trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => {
const entry: MessageEntry = {
id: crypto.randomUUID(),
timestamp: new Date(),
direction: "request",
origin,
message,
};
this.dispatchTypedEvent("message", entry);
},
trackResponse: (
message: JSONRPCResultResponse | JSONRPCErrorResponse,
origin: MessageOrigin,
) => {
const entry: MessageEntry = {
id: crypto.randomUUID(),
timestamp: new Date(),
direction: "response",
origin,
message,
};
this.dispatchTypedEvent("message", entry);
},
trackNotification: (
message: JSONRPCNotification,
origin: MessageOrigin,
) => {
const entry: MessageEntry = {
id: crypto.randomUUID(),
timestamp: new Date(),
direction: "notification",
origin,
message,
};
this.dispatchTypedEvent("message", entry);
},
};
}
private attachTransportListeners(baseTransport: Transport): void {
baseTransport.onclose = () => {
// An explicit disconnect() owns the teardown and will set the canonical
// status + fire `disconnect` itself. Defer to it so the event fires
// exactly once whether or not the SDK calls onclose synchronously inside
// close() — without this, an onclose that runs while status is held at
// "error" would fire `disconnect`, then disconnect()'s own guard would
// fire it again (#1490 re-review).
if (this.disconnecting) return;
// Already fully torn down — nothing to do (avoids a duplicate
// `disconnect` event after an explicit disconnect()).
if (this.status === "disconnected") return;
// Do NOT let a trailing `onclose` downgrade a crash's "error" status to
// "disconnected". On a real mid-session crash many SDK transports fire
// BOTH `onclose` and `onerror` in a transport-dependent order; with the
// old `!== "disconnected"` guard the final status differed by ordering
// ("disconnected" when onerror landed first, "error" when onclose did).
// Treating "error" as terminal here makes "error" the canonical resting
// status in both orderings (#1490). We still emit the `disconnect` event
// below so session-teardown consumers fire identically either way; only
// the persistent status value is held at "error".
if (this.status !== "error") {
this.status = "disconnected";
this.dispatchTypedEvent("statusChange", this.status);
}
// A mid-session crash ends the connection without going through
// `disconnect()`, so drop anything the server had queued with us — the
// same reasoning as the `connect()` failure path. Cleared before the
// `disconnect` event so a consumer reading the queue while handling it
// sees it empty. `disconnect()` clears at the same point, but batches its
// change events with the rest of its teardown dispatches — so on that
// path the *events* land just after its `disconnect`, not before.
this.clearAndAnnouncePendingPeerRequests();
// Same for what *we* asked the server. The SDK's chained `_onclose`
// settles its own `_responseHandlers`, but the raw-wire map (the modern
// `tasks/*` frames its era gate refuses to route) is ours and it doesn't
// know about it — so a Tasks-tab poll in flight when the server dies
// would otherwise wait out its own 30s timeout and blame the timeout for
// a crash. Rejecting a settled promise is a no-op and the helper clears
// the map, so this can't double-settle with `disconnect()`.
this.rejectPendingRawWireRequests("Connection closed");
this.dispatchTypedEvent("disconnect");
};
baseTransport.onerror = (error: Error) => {
// Suppress ONLY the handshake case. These listeners are attached before
// the handshake runs (see connect()), so an SDK transport that reports a
// connect-time error via `onerror` — in addition to rejecting connect()
// — would otherwise dispatch the `error` event for a failure the awaited
// connect() rejection already surfaces, double-reporting it. "connecting"
// is precisely that state: the only one with a pending awaited connect()
// that will reject.
//
// We deliberately do NOT guard on `!== "connected"`: on a real
// mid-session crash many transports fire BOTH `onclose` and `onerror`,
// and the order is transport-dependent. If `onclose` lands first it flips
// status to "disconnected", so a "connected"-only guard would swallow the
// reason that the trailing `onerror` carries (its sole surface). Firing
// from any non-"connecting" state captures the reason regardless of
// ordering.
if (this.status === "connecting") return;
this.status = "error";
this.dispatchTypedEvent("statusChange", this.status);
this.dispatchTypedEvent("error", error);
};
}
/**
* Build RequestOptions for SDK client calls (timeout, resetTimeoutOnProgress, onprogress).
* When timeout is unset, SDK uses DEFAULT_REQUEST_TIMEOUT_MSEC (60s).
*
* When progress is enabled, we pass a per-request onprogress so the SDK routes progress and
* runs timeout reset. The SDK injects progressToken: messageId; we do not expose the caller's
* token to the server. We collect it from metadata and inject it into dispatched progressNotification
* events only, so listeners can correlate progress with the request that triggered it.
*
* @param progressToken Optional token from request metadata; injected into progressNotification
* events when provided (not sent to server).
*/
/**
* Merge per-call metadata with this client's `defaultMetadata` (from
* `InspectorClientOptions.defaultMetadata`, set from
* `InspectorServerSettings.metadata`). Call-time keys override defaults.
* Returns `undefined` when the combined map is empty so callers can skip
* injecting an empty `_meta` field.
*/
private mergeMeta(
callMetadata?: Record<string, string>,
): Record<string, string> | undefined {
const defaults = this.defaultMetadata;
// Modern-era per-request log level (#1629): stamp the opt-in `_meta` key on
// every request so the server emits `notifications/message` on this
// request's stream. Gated on the negotiated era — legacy servers use
// `logging/setLevel` instead, so we never stamp it there. Placed before the
// call-time keys so an explicit per-call `logLevel` (if ever passed) wins.
const logMeta =
this.protocolEra === "modern" && this.modernLogLevel
? { [LOG_LEVEL_META_KEY]: this.modernLogLevel }
: undefined;
const merged = {
...(defaults ?? {}),
...(logMeta ?? {}),
...(callMetadata ?? {}),
};
return Object.keys(merged).length > 0 ? merged : undefined;
}
private getRequestOptions(
progressToken?: ProgressToken,
signal?: AbortSignal,
): RequestOptions {
const opts: RequestOptions = {
resetTimeoutOnProgress: this.resetTimeoutOnProgress,
};
if (this.requestTimeout !== undefined) {
opts.timeout = this.requestTimeout;
}
// When provided, aborting this signal makes the SDK send a
// `notifications/cancelled` for the request and reject it (#1458).
if (signal) {
opts.signal = signal;
}
if (this.progress) {
const token = progressToken;
const onprogress: ProgressCallback = (progress: Progress) => {
const payload: Progress & { progressToken?: ProgressToken } = {
...progress,
...(token != null && { progressToken: token }),
};
this.dispatchTypedEvent("progressNotification", payload);
};
opts.onprogress = onprogress;
}
return opts;
}
/**
* {@link getRequestOptions} plus the per-call `cacheMode` for the SDK's
* cacheable verbs (the high-level `client.listTools()` / `listPrompts()` /
* `listResources()` / `listResourceTemplates()` used by the `listAll*`
* aggregate methods below). `cacheMode` is only honored by those wrappers —
* the single-page `client.request` path ignores it — so it lives here rather
* than in `getRequestOptions`. Omitted when unset so the SDK default
* (`'use'`) applies.
*/
private getCacheableRequestOptions(
cacheMode?: CacheMode,
): CacheableRequestOptions {
const opts: CacheableRequestOptions = this.getRequestOptions();
if (cacheMode !== undefined) {
opts.cacheMode = cacheMode;
}
return opts;
}
/**
* Build the `params` for the aggregate `listAll*` verbs: merge call metadata
* with `defaultMetadata` and wrap as `{ _meta }`, or `undefined` when empty
* (so the SDK skips an empty `_meta`). Shared by all four `listAll*` methods
* so the merge/omit branch is defined once.
*/
private aggregateListParams(
metadata?: Record<string, string>,
): { _meta: Record<string, string> } | undefined {
const effectiveMeta = this.mergeMeta(metadata);
return effectiveMeta ? { _meta: effectiveMeta } : undefined;
}
private isHttpOAuthConfig(): boolean {
const serverType = getServerTypeFromConfig(this.transportConfig);
return (
(serverType === "sse" || serverType === "streamable-http") &&
!!this.oauthManager
);
}
/**
* True when task status is completed, failed, or cancelled.
* We use this private helper instead of the SDK's experimental isTerminal()
* to avoid depending on experimental API and to get a type predicate so
* TypeScript narrows status to "completed" | "failed" | "cancelled" after the check.
*/
private static isTerminalTaskStatus(
status: Task["status"],
): status is "completed" | "failed" | "cancelled" {
return (
status === "completed" || status === "failed" || status === "cancelled"
);
}
/**
* Route a receiver (server-initiated) task-augmented `sampling/createMessage`
* or `elicitation/create` response around the v2 Client's result validation.
*
* SDK v2's `Client` wraps every spec request handler (`_wrapHandler`) to
* validate the result it returns — for sampling/elicitation it checks the
* value against `CreateMessageResult` / `ElicitResult` and rejects anything
* else with a `-32602`. The 2025-11-25 task flow answers a task-augmented
* request with a `CreateTaskResult` (`{ task }`), which that validation
* rejects — breaking server-initiated tasks that worked on the legacy client.
*
* There is no public seam to opt a handler out of result validation, so we
* swap the wrapped entry in the Protocol's private `_requestHandlers` map for
* one that dispatches the task-augmented branch straight through the raw
* handler (whose `{ task }` return then rides the legacy codec's pass-through
* `encodeResult` to the wire), while ordinary (non-task) requests keep the
* validating path. Mirrors the bypass a legacy server needs to emit `{ task }`.
* Delete once the SDK models task-augmented results natively (see #1624 stack).
*/
private installReceiverTaskResponseBypass(
method: "sampling/createMessage" | "elicitation/create",
rawHandler: (
request: CreateMessageRequest & ElicitRequest,
) => Promise<CreateMessageResult> | Promise<ElicitResult>,
): void {
if (!this.client) return;
// SDK gap: `Client` exposes no public way to (a) read a registered request
// handler or (b) opt one out of the result validation its `_wrapHandler`
// installs, so we reach the private `_requestHandlers` map through a
// narrowed cast. A public "register a raw/unvalidated handler" API — or a
// handler-result type that includes `CreateTaskResult` — would remove both
// this cast and the ones on the sampling/elicit returns above.
const internal = this.client as unknown as {
_requestHandlers: Map<
string,
(request: unknown, ctx: unknown) => unknown
>;
};
const validating = internal._requestHandlers.get(method);
if (!validating) return;
internal._requestHandlers.set(method, (request, ctx) => {
const task = (request as { params?: { task?: unknown } })?.params?.task;
// The advertisement check is redundant here — this wrapper only exists
// when tasks are advertised — but it mirrors the handler branch below
// deliberately: the two must agree, so they read one predicate.
if (this.tasksCapabilityAdvertised && task != null) {
return rawHandler(request as CreateMessageRequest & ElicitRequest);
}
return validating(request, ctx);
});
}
private createReceiverTask(opts: {
ttl?: number;
initialStatus: Task["status"];
statusMessage?: string;
pollInterval?: number;
}): ReceiverTaskRecord {
const taskId = crypto.randomUUID();
const ttlMs =
opts.ttl ??
(typeof this.receiverTaskTtlMs === "function"
? this.receiverTaskTtlMs()
: this.receiverTaskTtlMs);
const now = new Date().toISOString();
const task: Task = {
taskId,
status: opts.initialStatus,
ttl: ttlMs,
createdAt: now,
lastUpdatedAt: now,
...(opts.pollInterval != null && { pollInterval: opts.pollInterval }),
...(opts.statusMessage != null && { statusMessage: opts.statusMessage }),
};
let resolvePayload!: (payload: ClientResult) => void;
let rejectPayload!: (reason?: unknown) => void;
const payloadPromise = new Promise<ClientResult>((resolve, reject) => {
resolvePayload = resolve;
rejectPayload = reject;
});
// Mark it handled. The real consumer is the server polling `tasks/result`
// (`getReceiverTaskPayload` returns this same promise, so a real awaiter
// still sees the rejection), but nothing has attached a handler while the
// task sits in `input_required` — and it can be rejected from there, by an
// explicit `tasks/cancel` or by teardown settling a queued sample. Without
// this, that reject surfaces as an unhandled rejection.
void payloadPromise.catch(() => {});
const record: ReceiverTaskRecord = {
task,
payloadPromise,
resolvePayload,
rejectPayload,
};
record.cleanupTimeoutId = setTimeout(() => {
record.cleanupTimeoutId = undefined;
this.receiverTaskRecords.delete(taskId);
}, ttlMs);
this.receiverTaskRecords.set(taskId, record);
return record;
}
private emitReceiverTaskStatus(task: Task): void {
if (!this.client) return;
try {
const notification = TaskStatusNotificationSchema.parse({
method: "notifications/tasks/status" as const,
params: task,
});
this.client.notification(notification).catch((err) => {
this.logger.warn(
{ err, taskId: task.taskId },
"receiver task status notification failed",
);
});
} catch (err) {
this.logger.warn(
{ err, taskId: task.taskId },
"receiver task status notification failed",
);
}
}
private upsertReceiverTask(updatedTask: Task): void {
const record = this.receiverTaskRecords.get(updatedTask.taskId);
if (record) {
record.task = updatedTask;
this.emitReceiverTaskStatus(updatedTask);
}
}
private getReceiverTask(taskId: string): ReceiverTaskRecord | undefined {
return this.receiverTaskRecords.get(taskId);
}
private listReceiverTasks(): Task[] {
return Array.from(this.receiverTaskRecords.values()).map((r) => r.task);
}
private async getReceiverTaskPayload(taskId: string): Promise<ClientResult> {
const record = this.receiverTaskRecords.get(taskId);
if (!record) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Unknown taskId: ${taskId}`,
);
}
return record.payloadPromise;
}
private cancelReceiverTask(taskId: string): Task {
const record = this.receiverTaskRecords.get(taskId);
if (!record) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Unknown taskId: ${taskId}`,
);
}
if (InspectorClient.isTerminalTaskStatus(record.task.status)) {
return record.task;
}
const now = new Date().toISOString();
const updatedTask: Task = {
...record.task,
status: "cancelled",
lastUpdatedAt: now,
};
record.task = updatedTask;
record.rejectPayload(new Error("Task cancelled"));
if (record.cleanupTimeoutId != null) {
clearTimeout(record.cleanupTimeoutId);
record.cleanupTimeoutId = undefined;
}
this.emitReceiverTaskStatus(updatedTask);
return updatedTask;
}
/**
* Drop the cached MCP transport without a full disconnect() teardown.
* Used when a pre-auth connect failed or tokens arrived after an unauthenticated
* transport was created, so the next connect() can attach authProvider.
*/
private async dropCachedTransport(): Promise<void> {
if (!this.baseTransport && !this.transport) {
this.transportHasAuthProvider = false;
return;
}
try {
await this.client?.close();
} catch {
// Ignore errors on close
}
this.baseTransport = null;
this.transport = null;
this.transportHasAuthProvider = false;
}
/**
* Register the handlers for requests the *server* makes of *us* —
* `roots/list`, `sampling/createMessage`, `elicitation/create`, and the
* receiver-side `tasks/*` polls.
*
* MUST be called before `client.connect()`. The matching capabilities are
* advertised on the `Client` at construction time, so from the moment
* `connect()` sends `notifications/initialized` the server is entitled to
* issue any of these requests. Registering afterwards leaves a window in
* which the SDK `Client` has no handler and answers `-32601 Method not
* found` — which is exactly what a server that asks for roots the instant it
* is initialized (e.g. `server-filesystem`, which learns its allowed
* directories that way) hits, while a server that asks later does not (#1797).
*
* Nothing here depends on the server's capabilities — only on constructor-set
* state — so there is nothing to wait for. Its sibling
* {@link registerPeerNotificationHandlers} does the same for the one
* notification handler in that position; the notification handlers that *do*
* gate on `this.capabilities` stay in `connect()`, after the handshake.
*/
private registerPeerRequestHandlers(): void {
// Gated on what was advertised, like the others — see
// `rootsCapabilityAdvertised`.
if (this.samplingCapabilityAdvertised && this.client) {
const samplingHandler = (
request: CreateMessageRequest,
): Promise<CreateMessageResult> => {
const paramsTask = (request.params as { task?: { ttl?: number } })
?.task;
if (this.tasksCapabilityAdvertised && paramsTask != null) {
const record = this.createReceiverTask({
ttl: paramsTask.ttl,
initialStatus: "input_required",
statusMessage: "Awaiting user input",
});
void (async () => {
const samplingRequest = new SamplingCreateMessage(
request,
(result) => {
record.resolvePayload(result);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: now,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
(error) => {
record.rejectPayload(error);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: now,
statusMessage:
error instanceof Error ? error.message : String(error),
};
record.task = updated;
this.upsertReceiverTask(updated);
},
(id) => this.removePendingSample(id),
);
this.addPendingSample(samplingRequest);
})();
// Task-augmented (2025-11-25) response: the server sent a
// task-augmented `sampling/createMessage`, so we reply with a
// `CreateTaskResult` (`{ task }`) rather than a `CreateMessageResult`.
// The v2 Client validates a spec handler's result and would reject
// `{ task }` with -32602; `installReceiverTaskResponseBypass` below
// routes this task-augmented branch around that validation so the
// legacy `{ task }` response reaches the wire. `taskResult` is typed
// as `CreateTaskResult` so its shape IS checked; the unavoidable
// `as unknown as CreateMessageResult` bridges the SDK gap — the 2-arg
// `setRequestHandler` overload types a sampling handler's return as
// `CreateMessageResult` only and doesn't model the (deprecated but
// wire-valid) task-augmented `CreateTaskResult`. A handler-result
// union `CreateMessageResult | CreateTaskResult` on the SDK side
// would remove this cast.
const taskResult: CreateTaskResult = { task: record.task };
return Promise.resolve(taskResult as unknown as CreateMessageResult);
}
return this.enqueuePendingSample(request, "server-request");
};
this.client.setRequestHandler("sampling/createMessage", samplingHandler);
// Registration, like the `setRequestHandler` above it — and the whole
// bypass mechanism (install, wrapper branch, handler branch) reads this
// one predicate, so the install can't drift from the branch it controls.
if (this.tasksCapabilityAdvertised) {
this.installReceiverTaskResponseBypass(
"sampling/createMessage",
samplingHandler,
);
}
}
// Gated on what was advertised, not on `this.elicit` — see the field's doc:
// an elicit option that enables no mode advertises nothing, and registering
// regardless throws before the handshake.
if (this.elicitationCapabilityAdvertised && this.client) {
const elicitHandler = (request: ElicitRequest): Promise<ElicitResult> => {
const paramsTask = (request.params as { task?: { ttl?: number } })
?.task;
if (this.tasksCapabilityAdvertised && paramsTask != null) {
const record = this.createReceiverTask({
ttl: paramsTask.ttl,
initialStatus: "input_required",
statusMessage: "Awaiting user input",
});
void (async () => {
const elicitationRequest = new ElicitationCreateMessage(
request,
(result) => {
record.resolvePayload(result);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: now,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
(id) => this.removePendingElicitation(id),
(error) => {
record.rejectPayload(error);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: now,
statusMessage: error.message,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
);
this.addPendingElicitation(elicitationRequest);
})();
// Task-augmented (2025-11-25) response — see the sampling handler
// above. Reply with a `CreateTaskResult` (`{ task }`), routed around
// the v2 Client's result validation by
// `installReceiverTaskResponseBypass` below. `taskResult` is typed so
// its shape is checked; the `as unknown as ElicitResult` bridges the
// same SDK gap as the sampling handler — the 2-arg `setRequestHandler`
// overload types an elicitation handler's return as `ElicitResult`
// only and doesn't model the task-augmented `CreateTaskResult`.
const taskResult: CreateTaskResult = { task: record.task };
return Promise.resolve(taskResult as unknown as ElicitResult);
}
return this.enqueuePendingElicitation(request, "server-request");
};
this.client.setRequestHandler("elicitation/create", elicitHandler);
// Registration, like the `setRequestHandler` above it — and the whole
// bypass mechanism (install, wrapper branch, handler branch) reads this
// one predicate, so the install can't drift from the branch it controls.
if (this.tasksCapabilityAdvertised) {
this.installReceiverTaskResponseBypass(
"elicitation/create",
elicitHandler,
);
}
}
// Gated on what was advertised at construction, and it has to be: the SDK
// asserts the matching client capability inside `setRequestHandler`, so
// registering this on a client built without `roots` throws "Client does
// not support roots capability". Since `capabilities.roots` is negotiated at
// `initialize` (set in the constructor) and `registerCapabilities` refuses
// to run after connect, a client that omits the option can never serve
// `roots/list` — which is why every client that may call `setRoots()` later
// must pass `roots` up front (web does; the CLI and TUI now do too — #1797).
if (this.rootsCapabilityAdvertised && this.client) {
this.client.setRequestHandler("roots/list", async () => {
return { roots: this.roots ?? [] };
});
}
// Set up receiver-task request handlers (server polls us for tasks/list,
// tasks/get, tasks/result, tasks/cancel). SDK v2 removed tasks from the
// spec-method set, so these register through the 3-arg custom form with an
// explicit params schema (from the deprecated-but-importable task request
// schemas). The `result` schema is intentionally omitted so the SDK does
// not validate our responder return — matching v1, where only the
// requester validated (our receiver `Task` may omit fields a strict result
// schema would require).
if (this.tasksCapabilityAdvertised && this.client) {
this.client.setRequestHandler(
"tasks/list",
{ params: ListTasksRequestSchema.shape.params },
async () => ({ tasks: this.listReceiverTasks() }),
);
this.client.setRequestHandler(
"tasks/get",
{ params: GetTaskRequestSchema.shape.params },
async (params) => {
const record = this.getReceiverTask(params.taskId);
if (!record) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Unknown taskId: ${params.taskId}`,
);
}
return record.task;
},
);
this.client.setRequestHandler(
"tasks/result",
{ params: GetTaskPayloadRequestSchema.shape.params },
async (params) => this.getReceiverTaskPayload(params.taskId),
);
this.client.setRequestHandler(
"tasks/cancel",
{ params: CancelTaskRequestSchema.shape.params },
async (params) => this.cancelReceiverTask(params.taskId),
);
}
}
/**
* Register the inbound *notification* handlers that depend on nothing but
* constructor-set state, and so belong before the handshake for the same
* reason as {@link registerPeerRequestHandlers} (#1797).
*
* `notifications/roots/list_changed` is the only one — and note it is a
* **client**→server notification in the spec (`ClientNotification`): we send
* it from {@link setRoots}, servers do not normally send it to us. This
* inbound handler is defensive coverage for a non-conformant or experimental
* server, and its body dispatches `rootsChange` with our own already-known
* roots, i.e. a refresh signal carrying no new data. It sits here for
* consistency with the request handlers — it gates on no server capability,
* so there is nothing to wait for, and an unhandled notification is dropped
* silently by the SDK (no wire error) rather than answered `-32601`.
*
* The remaining listChanged handlers gate on `this.capabilities`, which is
* not populated until `fetchServerInfo()` runs, so they stay in `connect()`.
*/
private registerPeerNotificationHandlers(): void {
/* v8 ignore next -- unreachable: the sole caller is connect(), past its
`if (!this.client) throw`; the guard exists only to narrow the type. */
if (!this.client) return;
this.client.setNotificationHandler(
"notifications/roots/list_changed",
async () => {
// Re-dispatch our already-known roots as a refresh signal for the UI —
// the payload carries no new data (see the note on ownership above).
// Copied, as `setRoots` and `getRoots()` do: a listener must not be
// able to push into the list we advertise.
this.dispatchTypedEvent("rootsChange", [...(this.roots ?? [])]);
},
);
}
/**
* Stop the receiver tasks' TTL timers and drop the records.
*
* These are tasks a *server* created with us, so they belong to the session
* that created them: `listReceiverTasks()` is what the `tasks/list` handler
* answers with, and a record surviving into the next session would report a
* task the new server never created. `disconnect()` clears them, and so does
* `connect()` — the auth-recovery retry reconnects the *same* client
* instance, so ending the session isn't the only way a new one begins
* (#1797).
*/
private clearReceiverTasks(): void {
for (const record of this.receiverTaskRecords.values()) {
if (record.cleanupTimeoutId != null) {
clearTimeout(record.cleanupTimeoutId);
}
}
this.receiverTaskRecords.clear();
}
/**
* Reset the modern listen-stream cluster: the subscribed set, the stream
* state derived from it, and the reconnect machinery that reports on it.
*
* One helper because these move together — the rest of the file derives the
* stream's `active` from `subscribedResources.size > 0`, so clearing one
* without the other leaves a combination those readers treat as impossible,
* and a surviving `modernReconnectAttempts` makes a *new* session's first
* drop back off as if it were the old session's nth. Both axes are announced:
* every other mutation of the set dispatches, and so does the stream state.
*
* Closes the stream itself, best-effort. `disconnect()` is the obvious caller
* with a live one, but not the only one: an `onerror` without an `onclose`
* leaves the transport up, and `connect()` then reuses it — so the reference
* dropped here can be the last one to a stream still open on the server.
*/
private resetSubscriptionStream(): void {
const closing = this.modernSubscription;
this.subscribedResources.clear();
this.modernListenGeneration++;
this.clearModernReconnectTimer();
this.modernReconnectAttempts = 0;
this.modernSubscription = null;
// Announced only once both have moved: a listener that ran between them
// would see an empty set with an `active` stream — the combination this
// helper exists to prevent, so its own dispatches must not expose it.
this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
this.dispatchSubscriptionsChange();
// After the dispatches, so the ordering above is unaffected, and
// fire-and-forget because nothing downstream depends on it. That is also
// why the wrapping matters here in a different way than at the awaited
// sites: the `void` means a failure cannot reach the caller at all — so it
// cannot cut `disconnect()`'s straight-line teardown short — and what it
// would do instead is go unhandled, which ends a Node process by default.
if (closing) void closeSubscriptionBestEffort(closing);
}
/**
* Drop the session-scoped state a *new* session would misread — anything the
* next server could be told about, or that would change how we treat its
* traffic. State that only needs settling on the way out (the peer-request
* queues, the raw-wire map, the in-flight tool call) is not reset here — the
* in-flight tool call because `callTool`'s own `finally` releases it once the
* SDK rejects it, the other two because they are settled *end*-clean, by
* `disconnect()` and the crash path, so that a consumer handling the
* `disconnect` event already sees them empty. That is a difference in when,
* not in whether: the routes out do not cover every route *in* (an `onerror`
* without an `onclose` runs neither), so `connect()` sweeps those two beside
* this call as a backstop — see the comment there.
*
* `disconnect()` touches all of this on the way out too — two members through
* the same helpers, three hand-rolled in both places — so a sixth member
* added here has to be added there as well. One of the three is *paired*
* rather than duplicated: `modernLogLevel` is re-derived here and blanked
* there, deliberately (see the comment at that site). It is not the only
* way a session ends — a crash, or a failed connect the caller retries on
* this same instance (the auth-recovery path), both leave it behind. Called
* start-clean from `connect()` so every route in is covered.
*
* Each member has a symptom, not just untidiness: a stale `subscribedResources`
* entry makes the modern `subscribeToResource` early-return, so the user's
* Subscribe click silently sends nothing to the new server; a stale
* `cancelledTaskIds` entry mislabels a *new* task sharing the id as
* `cancelled` rather than `failed`; a stale subscription stream state reads
* `active` for a set that is now empty, which every reader of it treats as
* impossible; a receiver-task record is reported to the new server by
* `tasks/list`; and an un-aborted `taskInputAbortControllers`
* entry delays a paused poll loop unwinding — both registration sites release
* in a `finally`, so nothing leaks permanently; the abort just closes the
* window between the crash and the unwind (#1797).
*/
private resetSessionState(): void {
this.clearReceiverTasks();
this.resetSubscriptionStream();
this.cancelledTaskIds.clear();
for (const [, controller] of this.taskInputAbortControllers) {
controller.abort(new Error("Connection ended"));
}
this.taskInputAbortControllers.clear();
// Restore the configured opt-in rather than carrying a mid-session
// `setModernLogLevel` override into the next connection — and rather than
// leaving it `undefined` after a `disconnect()` cleared it, which silently
// dropped the user's configured level on reconnect (#1629, #1797).
this.modernLogLevel = resolveModernLogLevel(this.serverSettings);
}
/**
* Settle and drop the queued peer requests (sampling / elicitation).
*
* Every entry is settled before being dropped rather than discarded: an
* elicitation so an error-path `awaitUrlElicitation` — which blocks
* `callTool` — doesn't hang forever, and a sample so the *server* gets a
* response frame for the request we accepted (the transport can outlive a
* failed attempt; see the `connect()` catch). Callers dispatch the change
* events themselves:
* `disconnect()` batches them with its other teardown dispatches, and
* {@link clearAndAnnouncePendingPeerRequests} emits them immediately
* everywhere else.
*/
private clearPendingPeerRequests(): void {
for (const sample of this.pendingSamples) {
sample.cancel();
}
this.pendingSamples = [];
for (const elicitation of this.pendingElicitations) {
elicitation.cancel();
}
this.pendingElicitations = [];
}
/**
* {@link clearPendingPeerRequests} plus the change events, for every route
* that drops a queue without going through `disconnect()`: the routes *out*
* that end a connection some other way, plus the top of `connect()` as a
* backstop for the one route in that settles nothing (an `onerror` without an
* `onclose` — see the comment at that call). Named as a category rather than
* counted, because the set has grown before. (One of the routes out doesn't
* always end the connection: when a `connect()` failure leaves an auth
* provider holding the transport open, the caller re-authenticates and
* retries over it, and what's dropped is the queue left by the attempt that
* failed.)
*
* The events are the load-bearing half: `usePendingClientRequests` tracks its
* own state off them, so clearing the arrays without dispatching leaves the
* web pending-request modal on screen for a connection that is gone. Guarded
* on a non-empty queue so the paths that overlap (a `connect()` failure whose
* `dropCachedTransport` also fires `onclose`) announce it once.
*/
private clearAndAnnouncePendingPeerRequests(): void {
if (
this.pendingSamples.length === 0 &&
this.pendingElicitations.length === 0
) {
return;
}
this.clearPendingPeerRequests();
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
this.dispatchTypedEvent(
"pendingElicitationsChange",
this.pendingElicitations,
);
}
/**
* Connect to the MCP server
*/
async connect(): Promise<void> {
if (!this.client) {
throw new Error("Client not initialized");
}
if (this.status === "connected") {
return;
}
// Start from a clean session — see `resetSessionState` for why this is
// start-clean rather than relying on `disconnect()`.
this.resetSessionState();
// The two collections `resetSessionState` excludes as "settled on the way
// out", swept here as well — because one route out settles nothing. An
// `onerror` without an `onclose` only flips status to `"error"`: it runs
// neither teardown path, and it leaves `baseTransport` cached, so a
// `connect()` on this same instance reuses a *live* transport. That is the
// route the subscription-stream close exists for, and it strands these two
// the same way. The peer queue is the sharper of them — the web
// pending-request modal is derived from its length with no status gate, so
// it outlives the session, and a user answering it later would write
// *their* answer for the previous session's request id onto the new
// connection, arbitrarily far past the re-handshake. Note what the sweep
// does instead is emit a *cancel* for that same id, right here: still the
// settle-don't-discard rule, and this is the earliest moment available:
// the old connection is still the one on the wire here, and stays so at
// least until the conditional `dropCachedTransport()` below — which on a
// stdio server never runs at all, so the same transport carries straight
// through the re-handshake.
//
// Both helpers are idempotent (one guards on a non-empty queue, the other
// clears its map and re-rejecting a settled promise is a no-op), so these
// are no-ops on the routes that already ran them; and anything still
// pending here belongs to a session that is, by definition, no longer
// connected.
//
// Must stay *after* `resetSessionState()`, which reads as independent of it
// but is not: cancelling a task-augmented peer request settles it
// synchronously into the record callback, which ends in
// `upsertReceiverTask`. That is a no-op only because `clearReceiverTasks()`
// just emptied the map — hoisted above the reset, it would instead emit a
// `notifications/tasks/status` for the outgoing session's task, onto the
// transport this connect is about to reuse, moments before the reset drops
// the record anyway.
this.clearAndAnnouncePendingPeerRequests();
this.rejectPendingRawWireRequests("Connection ended");
const oauthManager = this.oauthManager;
if (
this.baseTransport &&
this.isHttpOAuthConfig() &&
oauthManager &&
!this.transportHasAuthProvider &&
!oauthManager.isEnterpriseManaged() &&
(await oauthManager.isOAuthAuthorized())
) {
await this.dropCachedTransport();
}
// Create transport (single place for create / wrap / attach).
if (!this.baseTransport) {
const transportOptions: CreateTransportOptions = {
fetchFn: this.fetchFn,
pipeStderr: this.pipeStderr,
onStderr: (entry: StderrLogEntry) => {
this.dispatchStderrLog(entry);
},
onFetchRequest: (entry: FetchRequestEntryBase) => {
this.dispatchFetchRequest({ ...entry, category: "transport" });
},
onFetchResponseBody: (id: string, body: string) => {
this.dispatchFetchRequestBodyUpdate(id, body);
},
...(this.serverSettings && { settings: this.serverSettings }),
};
if (this.isHttpOAuthConfig() && oauthManager) {
if (oauthManager.isEnterpriseManaged()) {
await oauthManager.trySilentEnterpriseManagedAuth();
const provider = await oauthManager.createOAuthProviderForTransport();
const tokens = await provider.tokens();
if (!tokens?.access_token) {
const err = new Error(
"Unauthorized: EMA resource access token unavailable",
) as Error & { status?: number; code?: number };
err.status = 401;
err.code = 401;
throw err;
}
transportOptions.authProvider = provider;
} else if (await oauthManager.isOAuthAuthorized()) {
// Without stored tokens, omit authProvider so connect() surfaces a plain
// 401 instead of the SDK opening a browser before the app callback
// server is listening (TUI/CLI run authenticate() explicitly).
transportOptions.authProvider =
await oauthManager.createOAuthProviderForTransport();
}
}
if (
this.directAuthRecovery &&
this.directAuthRecoveryActive !== false &&
this.isHttpOAuthConfig() &&
oauthManager &&
transportOptions.authProvider
) {
transportOptions.interceptAuthChallenges = true;
}
this.transportHasAuthProvider = !!transportOptions.authProvider;
const { transport: baseTransport } = this.transportClientFactory(
this.transportConfig,
transportOptions,
);
this.baseTransport = baseTransport;
if (this.directAuthRecovery) {
this.directAuthRecoveryActive = !(
baseTransport instanceof RemoteClientTransport
);
}
if (
baseTransport instanceof RemoteClientTransport &&
oauthManager &&
this.isHttpOAuthConfig()
) {
baseTransport.setAuthRecovery({
handleAuthChallenge: (challenge, options) =>
oauthManager.handleAuthChallenge(challenge, options),
pushAuthState: () => this.pushRemoteAuthState(),
});
baseTransport.setOnAuthChallenge((challenge) => {
void this.handleAmbientAuthChallenge(challenge);
});
}
const messageTracking = this.createMessageTrackingCallbacks();
this.transport = new MessageTrackingTransport(
baseTransport,
messageTracking,
{
rewriteIncomingResult: (message) =>
this.rewriteModernTaskResult(message),
consumeIncomingResponse: (message) =>
this.consumeRawWireResponse(message),
},
);
this.attachTransportListeners(this.baseTransport);
}
if (!this.transport) {
throw new Error("Transport not initialized");
}
try {
this.status = "connecting";
this.dispatchTypedEvent("statusChange", this.status);
// Register the handlers for server→client requests and the
// capability-independent notifications before the handshake — see
// `registerPeerRequestHandlers` for why the ordering is load-bearing.
this.registerPeerRequestHandlers();
this.registerPeerNotificationHandlers();
// Optional connect-time timeout from per-server settings. The MCP SDK
// has no connect-time timeout option, so we wrap the handshake in a
// Promise.race. On timeout, tear the transport down so the next
// connect() starts clean and the upstream socket isn't left hanging.
const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0;
const connectPromise = this.client.connect(this.transport);
const runConnect = async (): Promise<void> => {
if (connectTimeoutMs > 0) {
connectPromise.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Connection timed out after ${connectTimeoutMs} ms`,
),
),
connectTimeoutMs,
);
});
try {
await Promise.race([connectPromise, timeoutPromise]);
} finally {
if (timer) clearTimeout(timer);
}
} else {
await connectPromise;
}
};
try {
await this.invokeMcpClient(runConnect);
} catch (err) {
if (connectTimeoutMs > 0) {
await this.disconnect().catch(() => {});
}
throw err;
}
this.status = "connected";
this.dispatchTypedEvent("statusChange", this.status);
// Always fetch server info (capabilities, serverInfo, instructions) - this is just cached data from initialize.
// Must run BEFORE the "connect" event: the managed list-state managers
// refresh on "connect" and gate their list RPC on getCapabilities() (see
// #1395). If "connect" fired first, that gate would read undefined
// capabilities and wipe tools/prompts/resources to empty on every connect.
await this.fetchServerInfo();
this.dispatchTypedEvent("connect");
// Set initial logging level if configured and server supports it
if (this.initialLoggingLevel && this.capabilities?.logging) {
await this.client.setLoggingLevel(
this.initialLoggingLevel,
this.getRequestOptions(),
);
}
// Set up listChanged notification handlers based on config
if (this.client) {
// Tools listChanged handler
// Only register if both client config and server capability are enabled
if (
this.listChangedNotifications.tools &&
this.capabilities?.tools?.listChanged
) {
this.client.setNotificationHandler(
"notifications/tools/list_changed",
async () => {
// Always fire notification event (for tracking)
this.dispatchTypedEvent("toolsListChanged");
// Tools are managed by state managers; they can listen to toolsListChanged and refresh
},
);
}
// Note: If handler should not be registered, we don't set it
// The SDK client will ignore notifications for which no handler is registered
// Resources listChanged handler (state managers listen and refresh)
if (
this.listChangedNotifications.resources &&
this.capabilities?.resources?.listChanged
) {
this.client.setNotificationHandler(
"notifications/resources/list_changed",
async () => {
this.dispatchTypedEvent("resourcesListChanged");
this.dispatchTypedEvent("resourceTemplatesListChanged");
},
);
}
// Prompts listChanged handler (state managers listen and refresh)
if (
this.listChangedNotifications.prompts &&
this.capabilities?.prompts?.listChanged
) {
this.client.setNotificationHandler(
"notifications/prompts/list_changed",
async () => {
this.dispatchTypedEvent("promptsListChanged");
},
);
}
// Tasks list_changed and status handlers (when server advertises tasks
// capability). Both are custom (2025-11-25) notification methods absent
// from v2's spec-notification set, so they register through the 3-arg
// custom form with an explicit params schema.
if (this.capabilities?.tasks) {
this.client.setNotificationHandler(
"notifications/tasks/list_changed",
{ params: TasksListChangedNotificationSchema.shape.params },
async () => {
this.dispatchTypedEvent("tasksListChanged");
},
);
this.client.setNotificationHandler(
"notifications/tasks/status",
{ params: TaskStatusNotificationSchema.shape.params },
async (params) => {
const task = params as Task;
this.dispatchTypedEvent("taskStatusChange", {
taskId: task.taskId,
task,
});
},
);
}
// Resource updated notification handler (only if server supports subscriptions)
if (this.capabilities?.resources?.subscribe === true) {
this.client.setNotificationHandler(
"notifications/resources/updated",
async (notification) => {
const uri = notification.params.uri;
// Only process if we're subscribed to this resource
if (this.subscribedResources.has(uri)) {
this.dispatchTypedEvent("resourceUpdated", { uri });
}
},
);
}
// Elicitation complete notification (URL mode only): server notifies when out-of-band
// elicitation completes; we resolve the corresponding pending elicitation
if (this.urlElicitationCapabilityAdvertised) {
this.client.setNotificationHandler(
"notifications/elicitation/complete",
async (notification) => {
const { elicitationId } = notification.params;
const pending = this.pendingElicitations.find(
(e) =>
e.request.params?.mode === "url" &&
e.request.params?.elicitationId === elicitationId,
);
if (pending) {
// Resolve (not just remove): for the error-path retry loop this
// unblocks `awaitUrlElicitation`, and for request-path it sends
// the `accept` response the server is still awaiting. No-op once
// the user already clicked "I've completed it".
pending.completeIfPending();
}
},
);
}
// Progress: we use per-request onprogress (see getRequestOptions). We do not register
// a progress notification handler so the Protocol's _onprogress stays; timeout reset
// and routing work, and we inject the caller's progressToken into dispatched events.
}
} catch (error) {
if (!isConnectAuthRecoveryError(error)) {
this.status = "error";
this.dispatchTypedEvent("statusChange", this.status);
}
if (this.baseTransport && !this.transportHasAuthProvider) {
await this.dropCachedTransport();
}
// The peer handlers are registered before the handshake (#1797), so a
// server can queue a sampling/elicitation request during it — and this is
// where that connect attempt dies. Drop the queue: otherwise the UI keeps
// a live pending-request modal for a connection that never came up, and
// answering it would route to a transport that is either torn down or —
// when an auth provider holds it open, so the caller can re-authenticate
// and retry over it — carrying a queue from an attempt that already
// failed. Note the retention is gated on `transportHasAuthProvider`
// alone, independently of `isConnectAuthRecoveryError` above, which gates
// only the status hold. `disconnect()` does the same clearing, but
// `connect()` only reaches it on the connect-timeout path.
this.clearAndAnnouncePendingPeerRequests();
// Deliberately do NOT dispatch the `error` event here: this is the
// awaited `connect()` path, so re-throwing hands the reason straight to
// the caller. The `error` event is reserved for non-awaited transitions
// (the transport `onerror` above), where there is no promise to reject.
// Dispatching here too would double-report a handshake failure.
throw error;
}
}
/**
* Disconnect from the MCP server.
* @param safeDisconnectTimeout If > 0, poll every 10ms until SDK _responseHandlers is empty or this many ms have elapsed, then close. Default 0 = close immediately.
*/
async disconnect(safeDisconnectTimeout = 0): Promise<void> {
// Claim ownership of the teardown so a synchronous onclose (fired from
// within close() below) defers its status set + `disconnect` event to the
// canonical block at the end of this method. Reset before that block so
// its dispatch is unaffected; any later async onclose early-returns on the
// "disconnected" status guard. Guarantees a single `disconnect` event even
// when disconnecting from a held-"error" status (#1490 re-review).
this.disconnecting = true;
try {
if (this.client) {
if (safeDisconnectTimeout > 0) {
// This is pretty creepy, but there are test cases where client calls return but there
// are still response handlers pending. Usually a single macrotask delay is enough to
// clear them, but not always (it's been >10ms in some cases). The pending handlers
// themselves get the error (and in cases where those aren't awaited, the errors fly
// out of the test). This workaround where we directly access the handlers (otherwise
// private member of the SDK client) is creepy, but the least ugly working solution.
// We will re-valuate this with the v2 SDK. Currenly only tests that do quick disconnects
// use this setting.
//
const protocol = this.client as unknown as {
_responseHandlers?: Map<unknown, unknown>;
};
const handlers = protocol._responseHandlers;
const deadline = Date.now() + safeDisconnectTimeout;
while (
handlers?.size !== undefined &&
handlers.size > 0 &&
Date.now() < deadline
) {
await new Promise((r) => setTimeout(r, 10));
}
}
try {
await this.client.close();
} catch {
// Ignore errors on close
}
}
} finally {
// Release ownership before the canonical dispatch below so it runs
// normally; the "disconnected" status it sets makes any later async
// onclose early-return.
this.disconnecting = false;
}
// Null out transport so next connect() creates a fresh one.
this.baseTransport = null;
this.transport = null;
this.transportHasAuthProvider = false;
// Drop anything the server had queued with us before announcing the
// teardown, so a `disconnect` consumer sees an empty queue here as it does
// on the crash path. The change events stay batched with the other teardown
// dispatches below.
this.clearPendingPeerRequests();
// Update status - any onclose fired during close() above deferred to us
// (see `disconnecting`), so this is the single place the explicit-disconnect
// path settles the status and emits `disconnect`.
if (this.status !== "disconnected") {
this.status = "disconnected";
this.dispatchTypedEvent("statusChange", this.status);
this.dispatchTypedEvent("disconnect");
}
// Clear the rest of the server state (list state is in state managers).
// Clear resource subscriptions on disconnect. Tear down the modern listen
// stream (best-effort — the transport is already going away) and bump the
// generation so any in-flight re-listen/reconnect bails (#1630).
this.resetSubscriptionStream();
this.cancelledTaskIds.clear();
// Settle any pending raw-wire (modern tasks/*) requests so their callers
// don't hang past teardown. Rejected outright on every disconnect: the
// drain above polls the SDK's own response-handler map, which never holds
// raw-wire ids (those frames go straight through the transport), and it is
// opt-in anyway — every production caller leaves `safeDisconnectTimeout` at
// 0, so nothing is drained for anyone.
this.rejectPendingRawWireRequests("Disconnected");
// Abort any task paused at input_required so its poll loop unwinds.
for (const [, controller] of this.taskInputAbortControllers) {
controller.abort(new Error("Disconnected"));
}
this.taskInputAbortControllers.clear();
// Abort any in-flight ordinary tool call so its promise settles instead of
// hanging past teardown; drop the controller reference either way.
this.activeToolCallAbortController?.abort("Disconnected");
this.activeToolCallAbortController = undefined;
this.clearReceiverTasks();
this.appRendererClientProxy = null;
this.capabilities = undefined;
this.serverInfo = undefined;
this.instructions = undefined;
this.protocolVersion = undefined;
this.protocolEra = undefined;
this.discoverResult = undefined;
this.excludedTools = [];
// Read as "not opted in" while disconnected. This is no longer what stops
// it leaking into the next connection — `resetSessionState()` re-derives it
// at connect, so removing this would leak nothing (#1629). Note the web
// Logs control deliberately shows the *configured* level in this window
// (`resetSessionScopedUiState`), so the two disagree until the next
// connect re-seeds both; harmless, since nothing is sent meanwhile.
this.modernLogLevel = undefined;
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
this.dispatchTypedEvent(
"pendingElicitationsChange",
this.pendingElicitations,
);
this.dispatchTypedEvent("capabilitiesChange", this.capabilities);
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
this.dispatchTypedEvent("instructionsChange", this.instructions);
this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion);
this.dispatchTypedEvent("protocolEraChange", this.protocolEra);
this.dispatchTypedEvent("discoverResultChange", this.discoverResult);
this.dispatchTypedEvent("excludedToolsChange", this.excludedTools);
}
/**
* Returns a client proxy for use by AppRenderer / @mcp-ui. Delegates to the
* internal MCP Client. Returns null when not connected. Use this instead of
* accessing the raw client so behavior can be adapted here later if needed.
*/
getAppRendererClient(): AppRendererClient | null {
if (!this.client || this.status !== "connected") return null;
if (this.appRendererClientProxy !== null)
return this.appRendererClientProxy;
const target = this.client;
this.appRendererClientProxy = new Proxy(this.client, {
get(proxyTarget, prop, receiver) {
const value = Reflect.get(proxyTarget, prop, receiver);
if (prop === "setNotificationHandler" && typeof value === "function") {
return (schemaOrMethod: unknown, ...rest: unknown[]) => {
// `@modelcontextprotocol/ext-apps` still peers on SDK v1 and
// subscribes to list-changed notifications with the v1 schema-first
// API `setNotificationHandler(NotificationSchema, handler)`. SDK v2
// requires a method STRING as the first argument and throws
// "'[object Object]' is not a spec notification method" on a schema —
// which broke App rendering during the initial connect handshake.
// Translate a schema-first call to the method-string form; native
// string-first calls (ours) pass through untouched. Remove when
// ext-apps#702 ships a v2 peer.
const method =
typeof schemaOrMethod === "string"
? schemaOrMethod
: (notificationMethodFromSchema(schemaOrMethod) ??
schemaOrMethod);
return (value as (...a: unknown[]) => unknown).apply(target, [
method,
...rest,
]);
};
}
return value;
},
}) as AppRendererClient;
return this.appRendererClientProxy;
}
/**
* Send a ping request to the server. Resolves when the server responds.
*/
async ping(): Promise<void> {
if (!this.client) {
throw new Error("Client not initialized");
}
await this.client.request(
{ method: "ping" },
EmptyResultSchema,
this.getRequestOptions(),
);
}
/**
* Get the current connection status
*/
getStatus(): ConnectionStatus {
return this.status;
}
/**
* Get the MCP server configuration used to create this client
*/
getTransportConfig(): MCPServerConfig {
return this.transportConfig;
}
/**
* Get the server type (stdio, sse, or streamable-http)
*/
getServerType(): ServerType {
return getServerTypeFromConfig(this.transportConfig);
}
/**
* Get task capabilities from server
* @returns Task capabilities or undefined if not supported
*/
getTaskCapabilities(): { list: boolean; cancel: boolean } | undefined {
if (!this.capabilities?.tasks) {
return undefined;
}
return {
list: !!this.capabilities.tasks.list,
cancel: !!this.capabilities.tasks.cancel,
};
}
/**
* True when the connection is modern (2026-07-28) AND the server advertised
* the `io.modelcontextprotocol/tasks` extension (SEP-2663) in its
* `server/discover` capabilities. Modern task methods (`tasks/get`,
* `tasks/update`, `tasks/cancel`) and the "unsolicited task handle" behavior
* are gated on this — legacy servers use `capabilities.tasks` and the
* `tasks/list`-backed flow instead. Exposed so the Tasks tab and the modern
* task store gate on the extension rather than the legacy capability.
*/
isTasksExtensionNegotiated(): boolean {
return (
this.isModernEra() &&
this.capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined
);
}
/**
* Build the full modern (2026-07-28) per-request envelope for a RAW tasks/*
* request. The SDK's codec normally stamps this envelope, but raw requests
* bypass the codec, and the modern server rejects a request whose
* `MCP-Protocol-Version` header names 2026-07-28 but omits the required
* envelope `_meta` keys (`protocolVersion`, `clientInfo`, plus
* `clientCapabilities` carrying the tasks extension). We reproduce it here.
*/
private withModernTaskEnvelope(
params: Record<string, unknown>,
): Record<string, unknown> {
const existingMeta =
(params._meta as Record<string, unknown> | undefined) ?? {};
const clientCapabilities = {
...this.clientCapabilities,
// Force-stamp the tasks extension regardless of what the client
// advertised at construction: the raw `tasks/*` channel requires it, and
// a user may disable general tasks advertisement via `advertisedExtensions`
// (#1738). So this stamp is load-bearing, not a redundant re-add.
extensions: {
...this.clientCapabilities.extensions,
[TASKS_EXTENSION_KEY]: {},
},
};
// Use the NEGOTIATED protocol version so the envelope agrees with the
// `MCP-Protocol-Version` header the transport stamps from the same source —
// a future modern-family revision would negotiate a different string, and
// the two must not disagree. The raw channel only runs on a connected modern
// session, so this is always set; the constant is a defensive fallback.
/* v8 ignore start -- fallback only if getProtocolVersion() is unset, which
can't happen on the connected modern session this runs on. Bracketed so
the ignore is reflow-proof however prettier splits the statement. */
const protocolVersion =
this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION;
/* v8 ignore stop */
return {
...params,
_meta: {
...existingMeta,
[PROTOCOL_VERSION_META_KEY]: protocolVersion,
[CLIENT_INFO_META_KEY]: this.clientInfo,
[CLIENT_CAPABILITIES_META_KEY]: clientCapabilities,
},
};
}
/**
* Transport-level rewrite of a modern (SEP-2663) `CreateTaskResult`
* (`resultType: "task"`) — the one task frame the SDK v2 codec rejects (tasks
* were removed, so the codec knows only `complete`/`input_required`). The true
* frame is already logged by `trackResponse`; here we hand the SDK a benign
* `CallToolResult` that carries the real `DetailedTask` under
* {@link MODERN_TASK_HANDLE_META}, where {@link pollTaskToolCall} reads it to
* drive the poll. Any other message passes through untouched.
*/
private rewriteModernTaskResult(
message: JSONRPCResultResponse,
): JSONRPCMessage {
if (!isModernCreateTaskResult(message.result)) {
return message;
}
const task = message.result as ModernDetailedTask;
return {
...message,
result: {
resultType: "complete",
content: [{ type: "text", text: `Modern task ${task.taskId} created` }],
_meta: { [MODERN_TASK_HANDLE_META]: task },
},
};
}
/**
* Send an extension method the SDK v2 era gate refuses to route — the modern
* `tasks/get` / `tasks/update` / `tasks/cancel`, which are spec-method names
* absent from the 2026-07-28 era, so `client.request` throws
* `MethodNotSupportedByProtocolVersion` before anything reaches the wire.
*
* We mint a string JSON-RPC id (the SDK only mints numeric ids, so ours never
* collide), send the raw frame straight through the transport (which still
* logs it via `trackRequest`, so the Protocol/Network tabs see it), and await
* the matching response — captured and consumed by the transport's
* consume-response hook so it never confuses the SDK Client. The response is
* validated with the caller's explicit schema.
*/
private async rawWireRequest<T>(
method: string,
params: Record<string, unknown>,
resultSchema: { parse: (value: unknown) => T },
): Promise<T> {
const transport = this.transport;
if (!transport) {
throw new Error("Client is not connected");
}
const id = `inspector-ext-${(this.rawWireRequestCounter += 1)}`;
// `params` is an arbitrary caller-supplied record; the SDK types request
// params with a specific optional `_meta` shape it can't satisfy, so widen
// it with a single structural cast. Typing `message` as `JSONRPCRequest`
// (a `JSONRPCMessage` member) then needs no further cast.
const message: JSONRPCRequest = {
jsonrpc: "2.0",
id,
method,
params: params as JSONRPCRequest["params"],
};
const timeoutMs = this.requestTimeout ?? 30_000;
const raw = await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRawWireRequests.delete(id);
reject(
new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`),
);
}, timeoutMs);
this.pendingRawWireRequests.set(id, { resolve, reject, timer });
transport.send(message).catch((err: unknown) => {
const pending = this.pendingRawWireRequests.get(id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRawWireRequests.delete(id);
}
reject(err instanceof Error ? err : new Error(String(err)));
});
});
return resultSchema.parse(raw);
}
/**
* Transport consume-response hook: resolve/reject a pending
* {@link rawWireRequest} when its response arrives, and report it as consumed
* (so the transport does not forward it to the SDK Client, which never sent
* it). Returns false for any id we don't own, leaving normal SDK traffic
* untouched.
*/
private consumeRawWireResponse(
message: JSONRPCResultResponse | JSONRPCErrorResponse,
): boolean {
const id = String((message as { id?: unknown }).id);
const pending = this.pendingRawWireRequests.get(id);
if (!pending) {
return false;
}
this.pendingRawWireRequests.delete(id);
clearTimeout(pending.timer);
if ("error" in message) {
const err = (message as JSONRPCErrorResponse).error;
pending.reject(new Error(err?.message ?? `Request ${id} failed`));
} else {
pending.resolve((message as JSONRPCResultResponse).result);
}
return true;
}
/**
* Reject and clear all pending raw-wire requests — on every route out that
* can hold one, and at the top of `connect()` for the route in that settles
* nothing (see the comment there).
*/
private rejectPendingRawWireRequests(reason: string): void {
for (const [, pending] of this.pendingRawWireRequests) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
this.pendingRawWireRequests.clear();
}
/**
* Get requestor task status by taskId (tasks we created on the server)
* @param taskId Task identifier
* @returns Task status
*/
async getRequestorTask(taskId: string): Promise<Task> {
if (!this.client) {
throw new Error("Client is not connected");
}
// Modern (SEP-2663): `tasks/get` returns a `DetailedTask` (ttlMs/pollIntervalMs,
// inlined result/error/inputRequests) — a different wire shape than the
// deprecated SDK schema. Parse with the explicit modern schema and normalize
// onto the internal Task shape, stamping the extension client capability.
if (this.isTasksExtensionNegotiated()) {
const modern = await this.rawWireRequest(
"tasks/get",
this.withModernTaskEnvelope({ taskId }),
ModernGetTaskResultSchema,
);
const task = normalizeModernTask(modern);
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId: task.taskId,
task,
});
return task;
}
// Legacy (2025-11-25): SDK v2 removed `client.experimental.tasks.*`; drive
// the `tasks/get` wire method directly with its deprecated-but-importable
// result schema. `GetTaskResult` is the flattened task object.
const task = (await this.client.request(
{ method: "tasks/get", params: { taskId } },
GetTaskResultSchema,
this.getRequestOptions(),
)) as Task;
// Dispatch client-origin event (taskStatusChange is server-only)
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId: task.taskId,
task: task,
});
return task;
}
/**
* Get requestor task result by taskId (tasks we created on the server)
* @param taskId Task identifier
* @returns Task result
*/
async getRequestorTaskResult(taskId: string): Promise<CallToolResult> {
if (!this.client) {
throw new Error("Client is not connected");
}
// `tasks/result` returns the task's stored payload; for a task-augmented
// tool call that payload is a CallToolResult, so validate with
// CallToolResultSchema (replacing the removed experimental helper).
return await this.client.request(
{ method: "tasks/result", params: { taskId } },
CallToolResultSchema,
this.getRequestOptions(),
);
}
/**
* Cancel a running requestor task (task we created on the server)
* @param taskId Task identifier
* @returns Cancel result
*/
async cancelRequestorTask(taskId: string): Promise<void> {
if (!this.client) {
throw new Error("Client is not connected");
}
// Mark before awaiting: cancelling unblocks the in-flight callToolStream,
// whose error message may arrive before this resolves — the stream's error
// path reads this set to label the task "cancelled" rather than "failed".
this.cancelledTaskIds.add(taskId);
// If the task is paused at `input_required` (its poll loop blocked on the
// pending-request modal), abort it so the modal closes and the poll observes
// the cancellation — otherwise the user is stuck answering a modal that a
// non-advancing server would keep re-showing.
const inputAbort = this.taskInputAbortControllers.get(taskId);
if (inputAbort) {
inputAbort.abort(new Error(`Task ${taskId} cancelled by user`));
}
// Modern `tasks/cancel` is a raw-wire request (the SDK era gate blocks the
// spec-method name on 2026-07-28); legacy uses the SDK path + deprecated
// schema.
if (this.isTasksExtensionNegotiated()) {
await this.rawWireRequest(
"tasks/cancel",
this.withModernTaskEnvelope({ taskId }),
ModernCancelTaskResultSchema,
);
} else {
await this.client.request(
{ method: "tasks/cancel", params: { taskId } },
CancelTaskResultSchema,
this.getRequestOptions(),
);
}
// Dispatch event
this.dispatchTypedEvent("taskCancelled", { taskId });
}
/**
* Fulfil the outstanding `inputRequests` of a modern (SEP-2663)
* `input_required` task by sending `tasks/update` with the collected
* `inputResponses`. The server acks with an empty result; the task's
* observable status advances on a subsequent `tasks/get` poll (the update is
* eventually consistent). Modern-only — legacy tasks surface input through the
* server→client request channel, not `tasks/update`.
*
* @param taskId Task identifier
* @param inputResponses Responses keyed by the server's `inputRequests` ids
*/
async updateRequestorTask(
taskId: string,
inputResponses: Record<string, unknown>,
): Promise<void> {
if (!this.client) {
throw new Error("Client is not connected");
}
await this.rawWireRequest(
"tasks/update",
this.withModernTaskEnvelope({ taskId, inputResponses }),
ModernUpdateTaskResultSchema,
);
}
/**
* Cancel the in-flight ordinary (non-task) tool call started by
* {@link callTool}. Aborting its request makes the SDK send a
* `notifications/cancelled` to the server (the MCP cancellation flow) and
* reject the pending call, which `callTool` surfaces as a
* {@link ToolCallCancelledError}.
*
* Task-augmented calls have a server-side task and are cancelled via
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
* no ordinary call is in flight).
*
* @returns `true` if a call was in flight to cancel, `false` otherwise.
*/
cancelToolCall(): boolean {
const controller = this.activeToolCallAbortController;
if (!controller) {
return false;
}
// Drop the reference up front so a rapid second cancel is a clean no-op and
// can't re-abort a call that's already terminating.
this.activeToolCallAbortController = undefined;
// The reason string rides along on the `notifications/cancelled` the SDK
// sends to the server (and lets the call's catch distinguish this deliberate
// cancel from other aborts of the same controller, e.g. a disconnect).
controller.abort(TOOL_CALL_CANCELLED_REASON);
return true;
}
/**
* List all requestor tasks with optional pagination (tasks we created on the server)
* @param cursor Optional pagination cursor
* @returns List of tasks with optional next cursor
*/
async listRequestorTasks(
cursor?: string,
): Promise<{ tasks: Task[]; nextCursor?: string }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const result = await this.client.request(
{ method: "tasks/list", params: cursor ? { cursor } : {} },
ListTasksResultSchema,
this.getRequestOptions(),
);
return { tasks: result.tasks as Task[], nextCursor: result.nextCursor };
}
/**
* Surface a sampling request through the pending-request UI and resolve with
* the user's answer. Shared by the inbound `sampling/createMessage` handler
* (legacy server→client request) and the MRTR driver (a modern
* `input_required` round embeds the request in a tool-call result). `origin`
* tags which of the two so the UI can show era-accurate semantics.
*
* Sampling has no decline/cancel action (unlike elicitation): the panel
* either sends a `CreateMessageResult` (resolve — echoed to the server) or
* Rejects (reject — which fails the tool call). A `signal` abort likewise
* rejects so the MRTR driver can abort the originating call. `signal` is only
* passed by the MRTR driver (the tool call's abort signal) — while the driver
* awaits an answer there is no in-flight SDK request to carry it.
*/
private enqueuePendingSample(
request: CreateMessageRequest,
origin: PendingRequestOrigin,
signal?: AbortSignal,
): Promise<CreateMessageResult> {
// A Promise's resolve/reject is idempotent (first settle wins, later ones
// no-op), so the respond/reject/abort paths need no extra guard against a
// double settle.
return new Promise<CreateMessageResult>((resolvePromise, rejectPromise) => {
const sample = new SamplingCreateMessage(
request,
resolvePromise,
rejectPromise,
(id) => this.removePendingSample(id),
origin,
);
this.addPendingSample(sample);
this.wirePendingAbort(signal, () => {
this.removePendingSample(sample.id);
rejectPromise(createPendingAbortError());
});
});
}
/**
* Surface an elicitation request through the pending-request UI and resolve
* with the user's answer. Shared by the inbound `elicitation/create` handler
* and the MRTR driver — see {@link enqueuePendingSample} for the `origin` /
* `signal` semantics. A declined/cancelled elicitation resolves with the
* corresponding `ElicitResult` (echoed to the server on retry); only a
* genuine failure or a `signal` abort rejects.
*/
private enqueuePendingElicitation(
request: ElicitRequest,
origin: PendingRequestOrigin,
signal?: AbortSignal,
): Promise<ElicitResult> {
// See {@link enqueuePendingSample} — Promise settle is idempotent.
return new Promise<ElicitResult>((resolvePromise, rejectPromise) => {
const elicitation = new ElicitationCreateMessage(
request,
resolvePromise,
(id) => this.removePendingElicitation(id),
rejectPromise,
origin,
);
this.addPendingElicitation(elicitation);
this.wirePendingAbort(signal, () => {
this.removePendingElicitation(elicitation.id);
rejectPromise(createPendingAbortError());
});
});
}
/**
* Reject a still-pending request when `signal` aborts (e.g. the user cancels
* the tool call while its MRTR round is awaiting an answer). No-op when
* `signal` is absent — the legacy inbound-handler path passes none.
*/
private wirePendingAbort(
signal: AbortSignal | undefined,
onAbort: () => void,
): void {
if (!signal) return;
/* v8 ignore next 4 -- unreachable in the MRTR flow: an abort during the
SDK request leg rejects `client.request` before we reach the pending
enqueue, so the signal is never already-aborted here; kept as a defensive
guard because addEventListener("abort") would not fire on a pre-aborted
signal. */
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
/**
* Drive a multi-round-trip request (SEP-2322 "MRTR") for one of the modern
* multi-round-trip methods (`tools/call`, `prompts/get`, `resources/read`).
*
* The client is constructed with `inputRequired: { autoFulfill: false }`, so
* an `input_required` result is handed back here (via `allowInputRequired`)
* instead of the SDK silently fulfilling and retrying. We surface each
* embedded request through the SAME pending-request UI the legacy
* server→client path uses (`fulfilInputRequests`), gather the bare results,
* and retry the ORIGINAL request with `inputResponses` + the echoed
* `requestState` on a fresh JSON-RPC id (`client.request` mints it). The loop
* runs until the server returns a complete result, bounded by
* {@link MRTR_MAX_ROUNDS}.
*
* On legacy connections a server never returns `input_required`, so the first
* response is always complete and this is a single `client.request` call.
*/
private async requestWithInputRequired<TSchema extends StandardSchemaV1>(
method: "tools/call" | "prompts/get" | "resources/read",
params: Record<string, unknown>,
resultSchema: TSchema,
requestOptions: RequestOptions,
): Promise<StandardSchemaV1.InferOutput<TSchema>> {
const client = this.client;
/* v8 ignore next 3 -- defensive: every caller (callTool/getPrompt/
readResource) already verified this.client is non-null before reaching
here, so this guard cannot trip in practice. */
if (!client) {
throw new Error("Client is not connected");
}
const wrapped = withInputRequired(resultSchema);
const signal = requestOptions.signal;
let round = 0;
let nextParams = params;
while (true) {
const outcome = await client.request(
{ method, params: nextParams },
wrapped,
{
...requestOptions,
allowInputRequired: true,
},
);
if (!isInputRequiredResult(outcome)) {
return outcome;
}
round += 1;
if (round > InspectorClient.MRTR_MAX_ROUNDS) {
throw new Error(
`Multi-round-trip "${method}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`,
);
}
const inputResponses = await this.fulfilInputRequests(
outcome.inputRequests,
signal,
);
// Retry re-issues the ORIGINAL params plus THIS round's answers and the
// server's opaque state token; the SDK assigns a fresh JSON-RPC id.
nextParams = {
...params,
...(inputResponses ? { inputResponses } : {}),
...(outcome.requestState !== undefined
? { requestState: outcome.requestState }
: {}),
};
}
}
/**
* Fulfil the embedded requests of one MRTR `input_required` round, keyed by
* the server-assigned identifiers echoed back in `inputResponses`. Sequential
* (one modal at a time) to keep the single-slot pending UI coherent. Returns
* `undefined` for a `requestState`-only round (no embedded requests); an empty
* `inputRequests` map yields an empty `{}`, which the retry echoes harmlessly.
*/
private async fulfilInputRequests(
inputRequests: InputRequests | undefined,
signal?: AbortSignal,
origin: PendingRequestOrigin = "input-required",
): Promise<Record<string, unknown> | undefined> {
if (!inputRequests) return undefined;
const responses: Record<string, unknown> = {};
for (const [key, embedded] of Object.entries(inputRequests)) {
responses[key] = await this.fulfilEmbeddedInputRequest(
embedded,
signal,
origin,
);
}
return responses;
}
/**
* Fulfil a single embedded input request. `roots/list` is auto-answered from
* the configured roots (consistent with the legacy `roots/list` handler — no
* pending UX); `elicitation/create` and `sampling/createMessage` surface
* through the pending-request UI tagged with `origin`. `origin` distinguishes
* an MRTR round (`"input-required"`, answer echoed as a retry) from a modern
* task round (`"task-input-required"`, answer submitted via `tasks/update`).
*/
private async fulfilEmbeddedInputRequest(
request: CreateMessageRequest | ElicitRequest | ListRootsRequest,
signal?: AbortSignal,
origin: PendingRequestOrigin = "input-required",
): Promise<unknown> {
switch (request.method) {
case "roots/list":
return { roots: this.roots ?? [] };
case "elicitation/create":
return this.enqueuePendingElicitation(request, origin, signal);
case "sampling/createMessage":
return this.enqueuePendingSample(request, origin, signal);
/* v8 ignore next 6 -- defensive: an SDK server rejects an unknown embedded
method before it reaches the wire, so this only guards against a
non-conformant hand-rolled server; not reproducible against the
SDK-based test servers. */
default:
throw new Error(
`Unsupported embedded input_required request method: ${
(request as { method: string }).method
}`,
);
}
}
/**
* Get all pending sampling requests
*/
getPendingSamples(): SamplingCreateMessage[] {
return [...this.pendingSamples];
}
/**
* Add a pending sampling request
*/
private addPendingSample(sample: SamplingCreateMessage): void {
this.pendingSamples.push(sample);
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
this.dispatchTypedEvent("newPendingSample", sample);
}
/**
* Remove a pending sampling request by ID
*/
removePendingSample(id: string): void {
const index = this.pendingSamples.findIndex((s) => s.id === id);
if (index !== -1) {
this.pendingSamples.splice(index, 1);
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
}
}
/**
* Get all pending elicitation requests
*/
getPendingElicitations(): ElicitationCreateMessage[] {
return [...this.pendingElicitations];
}
/**
* Add a pending elicitation request
*/
private addPendingElicitation(elicitation: ElicitationCreateMessage): void {
this.pendingElicitations.push(elicitation);
this.dispatchTypedEvent(
"pendingElicitationsChange",
this.pendingElicitations,
);
this.dispatchTypedEvent("newPendingElicitation", elicitation);
}
/**
* Remove a pending elicitation request by ID
*/
removePendingElicitation(id: string): void {
const index = this.pendingElicitations.findIndex((e) => e.id === id);
if (index !== -1) {
this.pendingElicitations.splice(index, 1);
this.dispatchTypedEvent(
"pendingElicitationsChange",
this.pendingElicitations,
);
}
}
/**
* Get server capabilities
*/
getCapabilities(): ServerCapabilities | undefined {
return this.capabilities;
}
/**
* Get the capabilities this client advertises to the server. Snapshotted
* from the initialize-time build in setupClient(); does not reflect later
* registerCapabilities() calls on the underlying SDK Client.
*/
getClientCapabilities(): ClientCapabilities {
return this.clientCapabilities;
}
/**
* Get server info (name, version)
*/
getServerInfo(): Implementation | undefined {
return this.serverInfo;
}
/**
* Get server instructions
*/
getInstructions(): string | undefined {
return this.instructions;
}
/**
* Get the MCP protocol version negotiated with the server. On a legacy
* connect this is the version from the initialize handshake (e.g.
* "2025-06-18"); on a modern connect it's the negotiated modern revision.
* Undefined when not connected.
*/
getProtocolVersion(): string | undefined {
return this.protocolVersion;
}
/**
* The protocol era negotiated with the server (SEP §7.8): `"legacy"` for the
* 2025-11-25 initialize handshake, `"modern"` for the 2026-era sessionless
* model. Populated for every era once connected — including a plain legacy
* (`mode: "legacy"`) connect, which the SDK reports as `"legacy"`. Undefined
* only when not connected (before connect / after disconnect). (#1626)
*/
getProtocolEra(): ProtocolEra | undefined {
return this.protocolEra;
}
/**
* The `server/discover` result captured on a probed (`"auto"`) or pinned
* (`"modern"`) connect — server identity, capabilities, and supported
* versions learned up front without an initialize handshake. Undefined when
* not connected or on a plain legacy connect. Persistable and feedable back
* to the SDK as `connect(transport, { prior })` for a zero-round-trip
* reconnect. (#1626)
*/
getDiscoverResult(): DiscoverResult | undefined {
return this.discoverResult;
}
/**
* The per-server settings this client was constructed with (headers,
* timeouts, roots, OAuth, the auto-refresh-on-list-changed option, etc.).
* Read by the managed list state to decide whether to auto-refresh on
* `list_changed` notifications (#1402).
*/
getServerSettings(): InspectorServerSettings | undefined {
return this.serverSettings;
}
/**
* Replace the in-memory per-server settings on a live client. Lets a settings
* edit (e.g. toggling auto-refresh-on-list-changed) take effect on the
* current connection without a reconnect — the managed list state reads
* `getServerSettings()` at notification time, so the next `list_changed`
* notification honors the new value (#1444). Connection-time inputs
* (transport, OAuth, timeouts) still only apply on the next connect.
*/
setServerSettings(settings: InspectorServerSettings): void {
this.serverSettings = settings;
}
/**
* Set the logging level for the MCP server (legacy era only).
*
* On legacy servers logging is session-scoped: one `logging/setLevel` request
* sets the level for all subsequent `notifications/message`. Modern servers
* removed this method — use {@link setModernLogLevel} there instead.
*
* @param level Logging level to set
* @throws Error if client is not connected or server doesn't support logging
*/
async setLoggingLevel(level: LoggingLevel): Promise<void> {
if (!this.client) {
throw new Error("Client is not connected");
}
if (!this.capabilities?.logging) {
throw new Error("Server does not support logging");
}
await this.client.setLoggingLevel(level, this.getRequestOptions());
}
/**
* Set (or clear) the modern-era per-request log level (#1629).
*
* On 2026-07-28 servers `logging/setLevel` is gone and there is no
* session-scoped level: the client opts into logs per request by stamping the
* `io.modelcontextprotocol/logLevel` `_meta` key, and the server MUST NOT emit
* `notifications/message` for requests that omit it. This stores the level so
* {@link mergeMeta} stamps it on every subsequent request; pass `undefined` to
* stop opting in (logs then stay silently absent). Takes effect immediately —
* no request is sent, and it is a no-op on the wire until the next request.
*
* @param level Level to stamp on every request, or `undefined` to opt out.
*/
setModernLogLevel(level: LoggingLevel | undefined): void {
this.modernLogLevel = level;
}
/** The modern-era per-request log level, or `undefined` when not opted in. */
getModernLogLevel(): LoggingLevel | undefined {
return this.modernLogLevel;
}
/**
* Fetch a single page of tools without updating the client's internal list.
*/
async listTools(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ tools: Tool[]; nextCursor?: string }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const effectiveMeta = this.mergeMeta(metadata);
const params: ListToolsRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.invokeMcpClient(() =>
this.client!.request(
{ method: "tools/list", params },
ListToolsResultSchema,
this.getRequestOptions(metadata?.progressToken),
),
);
const tools = [...(response.tools || [])];
return { tools, nextCursor: response.nextCursor };
}
/**
* Aggregate ALL pages of `tools/list` via the SDK's high-level
* `client.listTools()` — the cache-aware verb. Unlike the single-page
* {@link listTools} (raw `client.request`, for pagination debugging), this is
* the path the managed tool list uses on refresh: the SDK walks every page,
* applies the SEP-2243 `x-mcp-header` exclusion, and consults/writes its
* response cache. `cacheMode` selects the disposition (`'use'` serves a
* still-fresh cached list without a round trip; `'refresh'` always fetches
* and re-stores; `'bypass'` fetches without touching the cache) — only
* meaningful on modern servers that send `ttlMs` hints; a no-op on legacy
* (nothing is cached, so every call hits the wire regardless).
*/
async listAllTools(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
}): Promise<{ tools: Tool[] }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(() =>
this.client!.listTools(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
);
// Recompute the SEP-2243 excluded-tools set alongside the aggregate. The
// SDK already filtered `response.tools`, so it can't tell us what it
// dropped — {@link refreshExcludedTools} re-lists the RAW `tools/list` to
// find out. This is a SECOND, deliberately un-cached walk: on a modern
// non-stdio connection it roughly doubles the list round-trips per refresh
// (and runs even when the aggregate above was served from cache), because
// the raw per-page path has no response cache and the excluded set must
// reflect the current wire truth. Accepted for a debugging tool where the
// list is small and correctness of "why did this tool vanish" matters more
// than the extra request; it's a no-op (no round trip) on legacy/stdio.
// Kept best-effort: an error here must never fail the tools list itself.
await this.refreshExcludedTools(options?.metadata).catch(() => {});
return { tools: [...response.tools] };
}
/**
* Whether this connection excludes tools with invalid `x-mcp-header`
* annotations from `tools/list`, matching the SDK's gate: only the modern
* (2026-07-28) era on a non-stdio (Streamable HTTP / SSE) transport. Legacy
* and stdio keep such tools in the list, so there is nothing to surface.
*/
private excludesInvalidXMcpHeaderTools(): boolean {
return this.isModernEra() && this.getServerType() !== "stdio";
}
/** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */
getExcludedTools(): ExcludedTool[] {
return this.excludedTools;
}
/**
* Recompute the tools the SDK excludes from `tools/list` for invalid
* `x-mcp-header` annotations (SEP-2243), and emit `excludedToolsChange`.
* Returns `[]` without any round trip on connections that don't exclude
* (legacy/stdio). Otherwise walks every page of the RAW `tools/list` (which,
* unlike the SDK's high-level `listTools()`, is NOT filtered) and keeps the
* tools whose annotation scan fails, each with its reason. A repeating cursor
* stops the walk (non-converging-server guard, mirroring the SDK).
*/
async refreshExcludedTools(
metadata?: Record<string, string>,
): Promise<ExcludedTool[]> {
const excluded: ExcludedTool[] = [];
// Gated to connections that actually exclude; otherwise this is a pure
// no-op (no round trip). The raw `listTools` below guards the connection.
if (this.excludesInvalidXMcpHeaderTools()) {
const seenCursors = new Set<string>();
let cursor: string | undefined;
do {
const page = await this.listTools(cursor, metadata);
for (const tool of page.tools) {
const scan = scanXMcpHeaderDeclarations(tool.inputSchema);
if (!scan.valid) excluded.push({ tool, reason: scan.reason });
}
cursor = page.nextCursor;
if (cursor !== undefined) {
/* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */
if (seenCursors.has(cursor)) break;
seenCursors.add(cursor);
}
} while (cursor !== undefined);
}
this.excludedTools = excluded;
this.dispatchTypedEvent("excludedToolsChange", excluded);
return excluded;
}
/**
* Call a tool. Caller must provide the Tool (e.g. from a state manager).
* @param tool The tool to call (use tool.name for the request)
* @param args Tool arguments
* @param generalMetadata Optional general metadata
* @param toolSpecificMetadata Optional tool-specific metadata (takes precedence over general)
* @param taskOptions Optional task options (e.g. ttl) for task-augmented requests
* @returns Tool call response
*/
async callTool(
tool: Tool,
args: Record<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
taskOptions?: { ttl?: number },
options?: { skipOutputValidation?: boolean },
): Promise<ToolCallInvocation> {
if (!this.client) {
throw new Error("Client is not connected");
}
if (tool.execution?.taskSupport === "required") {
throw new Error(
`Tool "${tool.name}" requires task support. Use callToolStream() instead of callTool().`,
);
}
const request: ToolCallRequest = {
tool,
args,
generalMetadata,
toolSpecificMetadata,
taskOptions,
options,
};
// Track this call so `cancelToolCall()` can abort it. Aborting makes the SDK
// send a `notifications/cancelled` to the server (the MCP cancellation flow)
// and reject the pending request, which we surface as a
// `ToolCallCancelledError`. This single slot is shared by *every* `callTool`
// caller (last-writer-wins), so `cancelToolCall()` targets the most recently
// started ordinary call — fine today since the Cancel button only surfaces
// for the single Tools-screen call. Cleared in `finally`, but only if still
// ours so a later overlapping call's controller isn't clobbered (#1458).
const abortController = new AbortController();
this.activeToolCallAbortController = abortController;
try {
return await this.callToolWithRetries(request, abortController);
} finally {
if (this.activeToolCallAbortController === abortController) {
this.activeToolCallAbortController = undefined;
}
}
}
/**
* The URL-elicitation retry loop for {@link callTool}, factored out so the
* caller can wrap it in the abort-controller lifecycle (`try`/`finally`). On a
* user cancellation (the call's abort signal fired with the cancel reason) the
* SDK has already sent `notifications/cancelled`, so we throw a
* {@link ToolCallCancelledError} without recording a failed call — the cancel
* was intentional, not a failure.
*/
private async callToolWithRetries(
request: ToolCallRequest,
abortController: AbortController,
): Promise<ToolCallInvocation> {
const { tool, args, generalMetadata, toolSpecificMetadata } = request;
// Retry-loop state for the URL-elicitation error path: a `-32042`
// (UrlElicitationRequired) response means the server needs the user to
// complete one or more URL elicitations before the call can succeed. We
// surface them, wait for completion, then re-issue the same call. The
// counter bounds a server that keeps returning `-32042` so we can't spin
// forever (each accepted round is one attempt). `presentedUrls` guards the
// loop: a retry that re-requests a URL we already handled can't progress, so
// we abort with a UrlElicitationLoopError rather than re-prompt.
let urlElicitationAttempt = 0;
const presentedUrls = new Set<string>();
while (true) {
try {
return await this.attemptToolCall(request, abortController.signal);
} catch (error) {
// The controller was aborted. A deliberate `cancelToolCall()` (matched
// by reason) means the SDK already sent `notifications/cancelled` if the
// abort landed during a `client.request` leg — so surface a clean
// cancellation, not a generic failure, and don't record it in history.
// If instead the abort lands while an MRTR round is awaiting an embedded
// pending request (between `client.request` legs), there is no in-flight
// SDK request, so nothing is sent on the wire — `wirePendingAbort` just
// rejects the pending request and the driver abandons the retry; the
// outcome here is identical. Any other abort (e.g. a disconnect, which
// aborts with a different reason) falls through to the normal error path
// (#1458).
if (
abortController.signal.aborted &&
abortController.signal.reason === TOOL_CALL_CANCELLED_REASON
) {
throw new ToolCallCancelledError(tool.name);
}
const urlElicitations = getUrlElicitationsFromError(error);
if (
urlElicitations &&
urlElicitations.length > 0 &&
urlElicitationAttempt < MAX_URL_ELICITATION_RETRIES
) {
// Loop guard: the server repeated a URL we already handled this call.
const repeated = urlElicitations.find((e) =>
presentedUrls.has(e.url),
);
if (repeated) {
const loopError = new UrlElicitationLoopError(repeated.url);
this.dispatchFailedToolCall(
tool,
args,
generalMetadata,
toolSpecificMetadata,
loopError.message,
);
throw loopError;
}
urlElicitationAttempt++;
for (const e of urlElicitations) {
presentedUrls.add(e.url);
}
const action = await this.runUrlElicitations(urlElicitations);
if (action === "accept") {
continue;
}
// The user declined/cancelled a required URL elicitation, so the
// original call can't proceed. Surface it as a failed call with a
// clear reason instead of the raw "-32042" message.
const abortError = new Error(
`Tool call cancelled: required URL elicitation was ${
action === "decline" ? "declined" : "cancelled"
}.`,
);
this.dispatchFailedToolCall(
tool,
args,
generalMetadata,
toolSpecificMetadata,
abortError.message,
);
throw abortError;
}
// Not a URL-elicitation error (or the non-spec no-list variant, or
// retries exhausted): record + rethrow so the caller can surface it.
// The App distinguishes the no-list `-32042` case (a dedicated toast)
// via getUrlElicitationsFromError on the thrown error.
if (urlElicitations && urlElicitations.length > 0) {
// A non-empty list here means the retry cap was hit (the live path
// returns or continues). Log the give-up so a server that keeps
// demanding new URL elicitations is diagnosable rather than looking
// like an ordinary failure.
this.logger.warn(
{ tool: tool.name, attempts: urlElicitationAttempt },
`Tool "${tool.name}" still required URL elicitations after ${MAX_URL_ELICITATION_RETRIES} attempts; giving up.`,
);
}
this.dispatchFailedToolCall(
tool,
args,
generalMetadata,
toolSpecificMetadata,
error instanceof Error ? error.message : String(error),
);
throw error;
}
}
}
/**
* Run a single tools/call attempt: convert args, issue the request, validate,
* and return a successful {@link ToolCallInvocation}. Throws on any error
* (including a `-32042` UrlElicitationRequired response); {@link callTool}'s
* retry loop owns the elicitation handling and failure bookkeeping.
*/
private async attemptToolCall(
request: ToolCallRequest,
signal?: AbortSignal,
): Promise<ToolCallInvocation> {
const {
tool,
args,
generalMetadata,
toolSpecificMetadata,
taskOptions,
options,
} = request;
const client = this.client;
if (!client) {
throw new Error("Client is not connected");
}
let convertedArgs: Record<string, JsonValue> = args;
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
}
}
if (Object.keys(stringArgs).length > 0) {
const convertedStringArgs = convertToolParameters(tool, stringArgs);
convertedArgs = { ...args, ...convertedStringArgs };
}
// Merge general metadata with tool-specific metadata; tool-specific wins.
const callMetadata: Record<string, string> | undefined =
generalMetadata || toolSpecificMetadata
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
: undefined;
const timestamp = new Date();
// Fold in this client's defaultMetadata so server-wide _meta reaches
// the wire even when the caller passed nothing.
const metadata = this.mergeMeta(callMetadata);
const callParams: {
name: string;
arguments: Record<string, JsonValue>;
_meta?: Record<string, string>;
task?: { ttl: number };
} = {
name: tool.name,
arguments: convertedArgs,
_meta: metadata,
};
if (taskOptions?.ttl != null) {
callParams.task = { ttl: taskOptions.ttl };
}
const requestOptions = this.getRequestOptions(
metadata?.progressToken,
signal,
);
// Route through the MRTR driver (`requestWithInputRequired`) so a modern
// `input_required` result pauses at the pending-request UI and retries with
// the user's answer (#1704). Both eras use `client.request` with
// `CallToolResultSchema`; on legacy this is a single round. We deliberately
// do NOT use `client.callTool` (which would auto-fulfil / reject on an
// `input_required` result) — its only extra behavior over `request` is
// structuredContent output validation, which we already re-implement below
// via `validateToolOutput`. MCP Apps passthrough (skipOutputValidation)
// simply skips that check; both paths yield a CallToolResult once the
// driver returns a complete (non-`input_required`) result.
const rawResult = await this.invokeMcpClient(
() =>
this.requestWithInputRequired(
"tools/call",
callParams,
CallToolResultSchema,
requestOptions,
),
{ method: "tools/call", toolName: tool.name },
);
// Unsolicited modern task handle (SEP-2663): on a modern connection the
// server may answer ANY `tools/call` with a task rather than a result. The
// transport rewrote that frame into a `CallToolResult` carrying the real
// `DetailedTask` in `_meta`; poll it to completion here (the run-as-task
// path does the same via `callToolStream`) so the ordinary call resolves to
// the task's final result and the Tasks tab tracks it.
const taskHandle = (rawResult as CallToolResult)._meta?.[
MODERN_TASK_HANDLE_META
] as ModernDetailedTask | undefined;
const result = taskHandle
? await this.pollModernTaskToTermination(taskHandle)
: rawResult;
// Output-schema validation. SDK v2's `callTool` relaxed some checks (e.g. it
// no longer rejects a structuredContent with undeclared properties against a
// strict `additionalProperties: false` schema), so we run our own Ajv check
// to preserve the Inspector's v1 behavior:
// - default path: strict — a schema violation rejects the call (matching
// what a strict host would do), so the caller sees the error.
// - skipOutputValidation (MCP Apps passthrough): non-fatal — surface it as
// an advisory so a schema-violating-but-real result still reaches the app.
const outputValidationError = this.validateToolOutput(tool, result);
if (outputValidationError && !options?.skipOutputValidation) {
// Match the prior contract: on v1 a strict output-schema violation
// surfaced as the SDK's typed `McpError`/`ProtocolError` (code
// InvalidParams), not a bare Error — so downstream code that branches on
// `instanceof ProtocolError` / `error.code` keeps working.
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
outputValidationError,
);
}
const invocation: ToolCallInvocation = {
toolName: tool.name,
params: args,
result,
timestamp,
success: true,
metadata,
outputValidationError,
};
this.dispatchTypedEvent("toolCallResultChange", {
toolName: tool.name,
params: args,
result: invocation.result,
timestamp,
success: true,
metadata,
outputValidationError,
});
return invocation;
}
/**
* Record a failed tools/call as a `toolCallResultChange` event (history + the
* Tools panel) without throwing. {@link callTool} calls this before rethrowing
* so a failure — whether a transport error, a declined URL elicitation, or a
* non-spec `-32042` — lands in the request history exactly once.
*/
private dispatchFailedToolCall(
tool: Tool,
args: Record<string, JsonValue>,
generalMetadata: Record<string, string> | undefined,
toolSpecificMetadata: Record<string, string> | undefined,
errorMessage: string,
): void {
const callMetadata: Record<string, string> | undefined =
generalMetadata || toolSpecificMetadata
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
: undefined;
const metadata = this.mergeMeta(callMetadata);
this.dispatchTypedEvent("toolCallResultChange", {
toolName: tool.name,
params: args,
result: null,
timestamp: new Date(),
success: false,
error: errorMessage,
metadata,
});
}
/**
* Surface the URL elicitations carried by a `-32042` error, one at a time and
* in order (per the spec's "URL mode with elicitation required error" flow),
* returning as soon as the user declines/cancels one. Returns `"accept"` only
* when every elicitation was accepted, which is {@link callTool}'s signal to
* retry the original call.
*/
private async runUrlElicitations(
elicitations: ElicitRequestURLParams[],
): Promise<ElicitResult["action"]> {
for (const params of elicitations) {
const action = await this.awaitUrlElicitation(params);
if (action !== "accept") {
return action;
}
}
return "accept";
}
/**
* Add one error-path URL elicitation to the pending queue (so it renders in
* the same modal as request-path elicitations) and resolve with the user's
* action. Unlike the request-path handler there is no server request to
* answer — accepting it just unblocks the retry; the server's optional
* `notifications/elicitation/complete` resolves it as accepted too (via
* `completeIfPending`).
*/
private awaitUrlElicitation(
params: ElicitRequestURLParams,
): Promise<ElicitResult["action"]> {
return new Promise<ElicitResult["action"]>((resolve) => {
const request = {
method: "elicitation/create",
params,
} as ElicitRequest;
const message = new ElicitationCreateMessage(
request,
(result) => resolve(result.action),
(id) => this.removePendingElicitation(id),
);
this.addPendingElicitation(message);
});
}
/**
* Non-fatally validate a delivered tool result against the tool's outputSchema
* (used by the skipOutputValidation path). Delegates to the pure
* {@link validateToolOutput} helper with this client's lazily-built Ajv
* validator. Returns an advisory message, or undefined when valid.
*/
private validateToolOutput(
tool: Tool,
result: CallToolResult,
): string | undefined {
this.outputValidator ??= new AjvJsonSchemaValidator();
return validateToolOutput(this.outputValidator, tool, result);
}
/**
* When a modern (SEP-2663) task is `input_required`, fulfil its embedded
* `inputRequests` through the pending-request UI and submit them via
* `tasks/update`. No-op for any other status. Shared by the streaming
* ({@link pollTaskToolCall}) and ordinary ({@link pollModernTaskToTermination})
* poll loops so the input handling lives in one place.
*
* `priorRounds` is the count of `input_required` rounds already handled for
* this task; the return value is the updated count. A non-conformant server
* that keeps returning `input_required` without ever completing would
* otherwise re-prompt the user on every poll forever, so we bound it with the
* same {@link MRTR_MAX_ROUNDS} cap the MRTR driver uses.
*/
private async submitModernTaskInput(
detailed: ModernDetailedTask,
task: Task,
priorRounds: number,
signal?: AbortSignal,
): Promise<number> {
if (task.status !== "input_required") {
return priorRounds;
}
const rounds = priorRounds + 1;
if (rounds > InspectorClient.MRTR_MAX_ROUNDS) {
throw new Error(
`Modern task "${task.taskId}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`,
);
}
const inputResponses = await this.fulfilInputRequests(
this.tagInputRequestsWithTask(readInputRequests(detailed), task.taskId),
signal,
"task-input-required",
);
/* v8 ignore next 3 -- a conformant `input_required` task always carries
`inputRequests`, so `fulfilInputRequests` returns a (possibly empty)
object here, never undefined; the guard is defensive. */
if (inputResponses) {
await this.updateRequestorTask(task.taskId, inputResponses);
}
return rounds;
}
/**
* Stamp `_meta[RELATED_TASK_META_KEY]` with the owning task id on each embedded
* request of a modern task's `inputRequests`. The pending-request UI reads that
* id (via `ElicitationCreateMessage.taskId`) so its Cancel control can cancel
* the TASK — not just answer the request — when a task is paused at
* `input_required`.
*/
private tagInputRequestsWithTask(
inputRequests: InputRequests | undefined,
taskId: string,
): InputRequests | undefined {
/* v8 ignore next -- only called for an input_required task, which always
carries inputRequests; the undefined passthrough is defensive. */
if (!inputRequests) return inputRequests;
const tagged: Record<string, unknown> = {};
for (const [key, req] of Object.entries(inputRequests)) {
const request = req as { params?: { _meta?: Record<string, unknown> } };
tagged[key] = {
...request,
params: {
...request.params,
_meta: {
...request.params?._meta,
[RELATED_TASK_META_KEY]: { taskId },
},
},
};
}
return tagged as InputRequests;
}
/**
* Terminal outcome for a modern task: the inlined `CallToolResult` for a
* `completed` task (SEP-2663 removed the blocking `tasks/result`), or a
* `ProtocolError` for `failed` / `cancelled`. Shared so both poll loops agree
* on the result/error shape.
*/
private modernTaskTerminalOutcome(
task: Task,
detailed: ModernDetailedTask,
):
| { type: "result"; result: CallToolResult }
| { type: "error"; error: ProtocolError } {
if (task.status === "completed") {
/* v8 ignore next -- a conformant `completed` task always inlines its
`result`; the `{ content: [] }` fallback is defensive. */
return {
type: "result",
result: (detailed.result ?? { content: [] }) as CallToolResult,
};
}
return {
type: "error",
error: new ProtocolError(
ProtocolErrorCode.InternalError,
task.statusMessage ?? `Task ${task.status}`,
),
};
}
/**
* Poll cadence for a task: the server-advertised `pollInterval` when
* positive, else the default. Shared by every task poll loop (both eras).
*/
private taskPollInterval(task: Task): number {
const advertised = task.pollInterval;
if (typeof advertised !== "number") return DEFAULT_TASK_POLL_INTERVAL_MS;
// A spec-conformant server never advertises a non-positive interval; the
// `> 0` guard is defensive against a malformed value.
/* v8 ignore next -- non-positive pollInterval is unreachable from a conformant server. */
return advertised > 0 ? advertised : DEFAULT_TASK_POLL_INTERVAL_MS;
}
/**
* Register a per-task abort controller (keyed by taskId) whose signal gates
* the task's `input_required` pending request, and return the signal plus a
* `release` cleanup. {@link cancelRequestorTask} aborts it to unblock a task
* paused at the pending-request modal.
*/
private registerTaskInputAbort(taskId: string): {
signal: AbortSignal;
release: () => void;
} {
const controller = new AbortController();
this.taskInputAbortControllers.set(taskId, controller);
return {
signal: controller.signal,
release: () => {
// Only delete our own entry — tool calls are serial, so a second task
// never replaces this id's controller mid-poll; the guard is defensive.
/* v8 ignore next */
if (this.taskInputAbortControllers.get(taskId) === controller) {
this.taskInputAbortControllers.delete(taskId);
}
},
};
}
/**
* Drive a modern (SEP-2663) task to a terminal state from a seed
* `DetailedTask`, dispatching task events so the Tasks tab and toasts track
* it, and return the completed task's inlined `CallToolResult` (or throw on
* `failed` / `cancelled`). Used by the ORDINARY `callTool` path when a server
* returns an unsolicited task handle (the run-as-task streaming path drives
* the equivalent loop inline in {@link pollTaskToolCall}). `input_required`
* rounds are answered through the pending-request UI and submitted via
* `tasks/update`.
*/
private async pollModernTaskToTermination(
seed: ModernDetailedTask,
): Promise<CallToolResult> {
let detailed = seed;
let task = normalizeModernTask(detailed);
const emit = (t: Task): void => {
this.dispatchTypedEvent("toolCallTaskUpdated", {
taskId: t.taskId,
task: t,
});
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId: t.taskId,
task: t,
});
};
emit(task);
const { signal: inputSignal, release } = this.registerTaskInputAbort(
task.taskId,
);
try {
let inputRounds = 0;
while (!InspectorClient.isTerminalTaskStatus(task.status)) {
inputRounds = await this.submitModernTaskInput(
detailed,
task,
inputRounds,
inputSignal,
);
await new Promise((resolve) =>
setTimeout(resolve, this.taskPollInterval(task)),
);
detailed = await this.rawWireRequest(
"tasks/get",
this.withModernTaskEnvelope({ taskId: task.taskId }),
ModernGetTaskResultSchema,
);
task = normalizeModernTask(detailed);
emit(task);
}
} finally {
release();
}
const outcome = this.modernTaskTerminalOutcome(task, detailed);
if (outcome.type === "error") {
throw outcome.error;
}
return outcome.result;
}
/**
* Poll a task-augmented tool call to completion. Replaces the removed
* `client.experimental.tasks.callToolStream` helper: it sends the
* task-augmented `tools/call` (the server responds with a task handle, i.e. a
* `CreateTaskResult`), then polls `tasks/get` until the task reaches a
* terminal status, yielding the same `taskCreated | taskStatus | result |
* error` message shapes the caller's `for await` loop consumes — so all the
* downstream event dispatch and terminal-state handling stays unchanged.
*/
private async *pollTaskToolCall(
params: CallToolRequest["params"],
requestOptions: RequestOptions,
): AsyncGenerator<
| { type: "taskCreated"; task: Task }
| { type: "taskStatus"; task: Task }
| { type: "result"; result: CallToolResult }
| { type: "error"; error: ProtocolError }
> {
if (!this.client) {
throw new Error("Client is not connected");
}
const client = this.client;
// The server streams `notifications/progress` for a task AFTER the
// task-augmented `tools/call` has already returned its `{ task }` handle. But
// SDK v2 deletes a request's progress subscription the moment that request
// resolves, so those later ticks would be dropped. Capture the subscription
// id the SDK registers for this request (the only new key in the private
// `_progressHandlers` map) so we can keep the caller's `onprogress` alive
// through the poll and clean it up when the task terminates.
// SDK gap: `Client` exposes no public API to keep a progress subscription
// alive across a resolved request (or to subscribe to progress by token), so
// we reach the private `_progressHandlers` map through a narrowed cast. A
// public "durable progress subscription" hook would remove this cast.
const progressHandlers = (
client as unknown as {
_progressHandlers: Map<number, ProgressCallback>;
}
)._progressHandlers;
const keysBeforeRequest = new Set(progressHandlers.keys());
// Create the task-augmented tool call. A task-capable server returns a task
// handle (`CreateTaskResult` = `{ task }`), but a server that completes
// synchronously (or for which the tool forbids/ignores task augmentation)
// may return an immediate `CallToolResult` instead — accept either with a
// union schema and branch on the presence of `task`.
//
// NOTE: the LEGACY task path does NOT opt into `allowInputRequired` (MRTR
// over legacy tasks is out of scope for #1704). The MODERN path (SEP-2663)
// instead surfaces a task's `input_required` through `tasks/get`'s
// `inputRequests` and answers via `tasks/update` (handled in the poll loop
// below), reusing the same pending-request UI.
const modernTasks = this.isTasksExtensionNegotiated();
const requestPromise = client.request(
{
// On modern the SDK codec stamps the tasks-extension client capability
// into the request envelope (advertised at construction), so a server
// may answer with a `CreateTaskResult` — no per-call `_meta` needed.
method: "tools/call",
params,
},
// Modern: the SDK codec can't decode a `resultType: "task"` result, so the
// transport rewrote it to a `CallToolResult` carrying the task handle in
// `_meta` — parse as a CallToolResult and read the handle below. Legacy:
// accept a `{ task }` handle or an immediate result.
modernTasks
? CallToolResultSchema
: CreateTaskResultSchema.or(CallToolResultSchema),
requestOptions,
);
// The SDK registers the progress handler synchronously while constructing
// the request promise (before this await), so the new key is present now.
// ASSUMES SERIAL CONSTRUCTION: `find` takes the first key not present in the
// pre-request snapshot, which is unambiguous only because no OTHER request
// registers a progress handler between the snapshot and this request's
// synchronous registration. Tool calls are user-driven and serial, so that
// holds today; if concurrent task-augmented calls are ever constructed in
// the same microtask window, two subscription ids could cross-wire and this
// must move to an SDK-supported correlation (see the delete-when-native note
// on `installReceiverTaskResponseBypass`).
const progressSubscriptionId = requestOptions.onprogress
? [...progressHandlers.keys()].find((k) => !keysBeforeRequest.has(k))
: undefined;
const created = await requestPromise;
if (modernTasks) {
// Modern (SEP-2663): a task-creating `tools/call` came back as a
// `resultType: "task"` frame the SDK can't decode, so the transport
// rewrote it to a `CallToolResult` carrying the real `DetailedTask` under
// MODERN_TASK_HANDLE_META. A synchronous completion has no such handle —
// yield that `CallToolResult` directly.
const handle = (created as CallToolResult)._meta?.[
MODERN_TASK_HANDLE_META
] as ModernDetailedTask | undefined;
if (!handle) {
yield { type: "result", result: created as CallToolResult };
return;
}
let detailed = handle;
let task = normalizeModernTask(detailed);
yield { type: "taskCreated", task };
if (progressSubscriptionId != null && requestOptions.onprogress) {
progressHandlers.set(progressSubscriptionId, requestOptions.onprogress);
}
const { signal: inputSignal, release } = this.registerTaskInputAbort(
task.taskId,
);
let inputRounds = 0;
try {
while (!InspectorClient.isTerminalTaskStatus(task.status)) {
// `input_required`: fulfil the embedded server→client requests through
// the same pending-request UI the MRTR path uses, then submit them via
// `tasks/update`. The update is eventually consistent — the task's
// status advances on a following `tasks/get`, so keep polling
// (bounded by MRTR_MAX_ROUNDS against a server that never advances).
// `inputSignal` fires if the task is cancelled while paused here.
inputRounds = await this.submitModernTaskInput(
detailed,
task,
inputRounds,
inputSignal,
);
await new Promise((resolve) =>
setTimeout(resolve, this.taskPollInterval(task)),
);
detailed = await this.rawWireRequest(
"tasks/get",
this.withModernTaskEnvelope({ taskId: task.taskId }),
ModernGetTaskResultSchema,
);
task = normalizeModernTask(detailed);
yield { type: "taskStatus", task };
}
} finally {
release();
if (progressSubscriptionId != null) {
progressHandlers.delete(progressSubscriptionId);
}
}
// Modern removes the blocking `tasks/result`: a completed task inlines its
// CallToolResult; failed/cancelled surface as an error.
yield this.modernTaskTerminalOutcome(task, detailed);
return;
}
if (!("task" in created) || created.task == null) {
// Immediate result — no task was created; yield it directly.
yield { type: "result", result: created as CallToolResult };
return;
}
let task = created.task as Task;
yield { type: "taskCreated", task };
// Revive the (now-deleted) progress subscription for the poll so task-
// execution progress ticks reach the caller's `onprogress`.
if (progressSubscriptionId != null && requestOptions.onprogress) {
progressHandlers.set(progressSubscriptionId, requestOptions.onprogress);
}
try {
// Poll `tasks/get` until the task reaches a terminal status. Honour the
// server-advertised `pollInterval` when present, else the default cadence.
while (!InspectorClient.isTerminalTaskStatus(task.status)) {
await new Promise((resolve) =>
setTimeout(resolve, this.taskPollInterval(task)),
);
task = (await client.request(
{ method: "tasks/get", params: { taskId: task.taskId } },
GetTaskResultSchema,
this.getRequestOptions(),
)) as Task;
yield { type: "taskStatus", task };
}
} finally {
if (progressSubscriptionId != null) {
progressHandlers.delete(progressSubscriptionId);
}
}
if (task.status === "completed") {
const result = await client.request(
{ method: "tasks/result", params: { taskId: task.taskId } },
CallToolResultSchema,
this.getRequestOptions(),
);
yield { type: "result", result };
} else {
// failed | cancelled — surface as an error the caller's loop labels as
// "cancelled" (via cancelledTaskIds) or "failed". Carry a ProtocolError so
// the `error` payload matches the event map's type (the SDK helper this
// replaces also yielded a protocol-error-shaped value).
yield {
type: "error",
error: new ProtocolError(
ProtocolErrorCode.InternalError,
task.statusMessage ?? `Task ${task.status}`,
),
};
}
}
/**
* Call a tool with task support (streaming).
* Caller must provide the Tool (e.g. from a state manager).
* @param tool The tool to call (use tool.name for the request)
* @param args Tool arguments
* @param generalMetadata Optional general metadata
* @param toolSpecificMetadata Optional tool-specific metadata (takes precedence over general)
* @param taskOptions Optional task options (e.g. ttl) for task-augmented requests
* @returns Tool call response
*/
async callToolStream(
tool: Tool,
args: Record<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
taskOptions?: { ttl?: number },
): Promise<ToolCallInvocation> {
if (!this.client) {
throw new Error("Client is not connected");
}
try {
let convertedArgs: Record<string, JsonValue> = args;
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
}
}
if (Object.keys(stringArgs).length > 0) {
const convertedStringArgs = convertToolParameters(tool, stringArgs);
convertedArgs = { ...args, ...convertedStringArgs };
}
// Merge general metadata with tool-specific metadata; tool-specific wins.
const callMetadata: Record<string, string> | undefined =
generalMetadata || toolSpecificMetadata
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
: undefined;
const timestamp = new Date();
const metadata = this.mergeMeta(callMetadata);
// Call the streaming API
const streamParams: Record<string, unknown> = {
name: tool.name,
arguments: convertedArgs,
};
if (metadata) {
streamParams._meta = metadata;
}
if (taskOptions?.ttl != null) {
streamParams.task = { ttl: taskOptions.ttl };
}
let finalResult: CallToolResult | undefined;
let taskId: string | undefined;
let error: Error | undefined;
// Correlate progress → task. getRequestOptions already wires onprogress to
// dispatch the generic progressNotification (keyed by the caller's
// progressToken). Wrap it so each tick that arrives after the task is
// created also dispatches requestorTaskProgress tagged with the taskId
// this stream owns — the only place that mapping is known. Ticks before
// taskCreated (rare) just fall through to the generic event.
//
// Gate on `this.progress`, mirroring getRequestOptions: when progress is
// globally disabled there's no inner handler to wrap, and we must not
// attach one here either — doing so would request a progress token (and
// emit requestorTaskProgress) for task calls only, bypassing the toggle
// that governs every other call path.
const requestOptions = this.getRequestOptions(metadata?.progressToken);
if (this.progress) {
const innerOnProgress = requestOptions.onprogress;
requestOptions.onprogress = (progress: Progress) => {
innerOnProgress?.(progress);
if (taskId) {
this.dispatchTypedEvent("requestorTaskProgress", {
taskId,
progress,
});
}
};
}
const stream = this.pollTaskToolCall(
streamParams as CallToolRequest["params"],
requestOptions,
);
// Iterate through the async generator
for await (const message of stream) {
switch (message.type) {
case "taskCreated":
taskId = message.task.taskId;
this.dispatchTypedEvent("toolCallTaskUpdated", {
taskId: message.task.taskId,
task: message.task,
});
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId: message.task.taskId,
task: message.task,
});
break;
case "taskStatus":
if (!taskId) {
taskId = message.task.taskId;
}
this.dispatchTypedEvent("toolCallTaskUpdated", {
taskId: message.task.taskId,
task: message.task,
});
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId: message.task.taskId,
task: message.task,
});
break;
case "result":
finalResult = message.result as CallToolResult;
if (taskId) {
const completedTask: TaskWithOptionalCreatedAt = {
taskId,
ttl: null,
status: "completed",
statusMessage: "Task completed" as string,
lastUpdatedAt: new Date().toISOString(),
};
this.dispatchTypedEvent("toolCallTaskUpdated", {
taskId,
task: completedTask,
result: finalResult,
});
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId,
task: completedTask,
result: finalResult,
});
}
break;
case "error": {
const errorMessage =
message.error.message || "Task execution failed";
error = new Error(errorMessage);
if (taskId) {
// A user-cancelled task surfaces here as a generic error; report
// it as "cancelled" (not "failed") so the UI lands on the true
// terminal state immediately, matching what a refresh would show
// (#1455).
const cancelled = this.cancelledTaskIds.has(taskId);
// Consume the marker — task ids are single-use, so this keeps the
// set from growing across a long session of cancellations (the
// disconnect-clear stays the backstop for cancels whose task
// completed before the cancel landed and never hit this path).
this.cancelledTaskIds.delete(taskId);
const terminalTask: TaskWithOptionalCreatedAt = {
taskId,
ttl: null,
status: cancelled ? "cancelled" : "failed",
statusMessage: cancelled
? "Client cancelled task execution."
: errorMessage,
lastUpdatedAt: new Date().toISOString(),
};
this.dispatchTypedEvent("toolCallTaskUpdated", {
taskId,
task: terminalTask,
error: message.error,
});
this.dispatchTypedEvent("requestorTaskUpdated", {
taskId,
task: terminalTask,
error: message.error,
});
}
break;
}
}
}
// If we got an error, throw it
if (error) {
throw error;
}
// If we didn't get a result, something went wrong
// This can happen if the task completed but result wasn't in the stream
// Try to get it from the task result endpoint
if (!finalResult && taskId) {
try {
finalResult = await this.client.request(
{ method: "tasks/result", params: { taskId } },
CallToolResultSchema,
this.getRequestOptions(), // no metadata for fallback
);
} catch (resultError) {
throw new Error(
`Tool call did not return a result: ${resultError instanceof Error ? resultError.message : String(resultError)}`,
);
}
}
if (!finalResult) {
throw new Error("Tool call did not return a result");
}
const invocation: ToolCallInvocation = {
toolName: tool.name,
params: args,
result: finalResult,
timestamp,
success: true,
metadata,
};
this.dispatchTypedEvent("toolCallResultChange", {
toolName: tool.name,
params: args,
result: invocation.result,
timestamp,
success: true,
metadata,
});
return invocation;
} catch (error) {
// Merge general metadata with tool-specific metadata for error case
const callMetadata: Record<string, string> | undefined =
generalMetadata || toolSpecificMetadata
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
: undefined;
const timestamp = new Date();
const metadata = this.mergeMeta(callMetadata);
this.dispatchTypedEvent("toolCallResultChange", {
toolName: tool.name,
params: args,
result: null,
timestamp,
success: false,
error: error instanceof Error ? error.message : String(error),
metadata,
});
throw error;
}
}
/**
* List available resources with pagination support (stateless; state managers hold the list).
* @param cursor Optional cursor for pagination
* @param metadata Optional metadata to include in the request
* @returns Object containing resources array and optional nextCursor
*/
async listResources(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ resources: Resource[]; nextCursor?: string }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const effectiveMeta = this.mergeMeta(metadata);
const params: ListResourcesRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.invokeMcpClient(() =>
this.client!.request(
{ method: "resources/list", params },
ListResourcesResultSchema,
this.getRequestOptions(metadata?.progressToken),
),
);
return {
resources: response.resources || [],
nextCursor: response.nextCursor,
};
}
/**
* Aggregate ALL pages of `resources/list` via the SDK's high-level
* cache-aware `client.listResources()`. See {@link listAllTools} for the
* `cacheMode` semantics; this is the path the managed resource list uses on
* refresh.
*/
async listAllResources(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
}): Promise<{ resources: Resource[] }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(() =>
this.client!.listResources(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
);
return { resources: [...response.resources] };
}
/**
* Read a resource by URI
* @param uri Resource URI
* @param metadata Optional metadata to include in the request
* @returns Resource content
*/
async readResource(
uri: string,
metadata?: Record<string, string>,
): Promise<ResourceReadInvocation> {
if (!this.client) {
throw new Error("Client is not connected");
}
const effectiveMeta = this.mergeMeta(metadata);
const params: ReadResourceRequest["params"] = {
uri,
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
// MRTR-driven (#1704): a modern `resources/read` can return `input_required`
// (embedding an elicitation/sampling request); the driver pauses at the
// pending-request UI and retries with the answer. Legacy is a single round.
const result = await this.invokeMcpClient(
() =>
this.requestWithInputRequired(
"resources/read",
params,
ReadResourceResultSchema,
this.getRequestOptions(metadata?.progressToken),
),
{ method: "resources/read" },
);
const invocation: ResourceReadInvocation = {
result,
timestamp: new Date(),
uri,
metadata: effectiveMeta,
};
this.dispatchTypedEvent("resourceContentChange", {
uri,
content: invocation,
timestamp: invocation.timestamp,
});
return invocation;
}
/**
* Read a resource from a template by expanding the template URI with parameters
* This encapsulates the business logic of template expansion and associates the
* loaded resource with its template in InspectorClient state
* @param templateName The name/ID of the resource template
* @param params Parameters to fill in the template variables
* @param metadata Optional metadata to include in the request
* @returns The resource content along with expanded URI and template name
* @throws Error if template is not found or URI expansion fails
*/
async readResourceFromTemplate(
uriTemplate: string,
params: Record<string, string>,
metadata?: Record<string, string>,
): Promise<ResourceTemplateReadInvocation> {
if (!this.client) {
throw new Error("Client is not connected");
}
const uriTemplateString = uriTemplate;
// Expand the template's uriTemplate using the provided params
let expandedUri: string;
try {
const uriTemplate = new UriTemplate(uriTemplateString);
expandedUri = uriTemplate.expand(params);
} catch (error) {
throw new Error(
`Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`,
);
}
// Always fetch fresh content: Call readResource with expanded URI
const readInvocation = await this.readResource(expandedUri, metadata);
// Create the template invocation object. Use the merged metadata recorded
// by readResource so the template-level history matches what was sent.
const invocation: ResourceTemplateReadInvocation = {
uriTemplate: uriTemplateString,
expandedUri,
result: readInvocation.result,
timestamp: readInvocation.timestamp,
params,
metadata: readInvocation.metadata,
};
this.dispatchTypedEvent("resourceTemplateContentChange", {
uriTemplate: uriTemplateString,
content: invocation,
params,
timestamp: invocation.timestamp,
});
return invocation;
}
/**
* List resource templates with pagination support (stateless; state managers hold the list).
* @param cursor Optional cursor for pagination
* @param metadata Optional metadata to include in the request
* @returns Object containing resourceTemplates array and optional nextCursor
*/
async listResourceTemplates(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const effectiveMeta = this.mergeMeta(metadata);
const params: ListResourceTemplatesRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.invokeMcpClient(
() =>
this.client!.request(
{ method: "resources/templates/list", params },
ListResourceTemplatesResultSchema,
this.getRequestOptions(metadata?.progressToken),
),
{ method: "resources/templates/list" },
);
return {
resourceTemplates: response.resourceTemplates || [],
nextCursor: response.nextCursor,
};
}
/**
* Aggregate ALL pages of `resources/templates/list` via the SDK's high-level
* cache-aware `client.listResourceTemplates()`. See {@link listAllTools} for
* the `cacheMode` semantics; this is the path the managed resource-template
* list uses on refresh.
*/
async listAllResourceTemplates(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
}): Promise<{ resourceTemplates: ResourceTemplate[] }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(
() =>
this.client!.listResourceTemplates(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
{ method: "resources/templates/list" },
);
return { resourceTemplates: [...response.resourceTemplates] };
}
/**
* List available prompts with pagination support
* @param cursor Optional cursor for pagination
* @param metadata Optional metadata to include in the request
* @returns Object containing prompts array and optional nextCursor
*/
async listPrompts(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ prompts: Prompt[]; nextCursor?: string }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const effectiveMeta = this.mergeMeta(metadata);
const params: ListPromptsRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.invokeMcpClient(() =>
this.client!.request(
{ method: "prompts/list", params },
ListPromptsResultSchema,
this.getRequestOptions(metadata?.progressToken),
),
);
return {
prompts: response.prompts || [],
nextCursor: response.nextCursor,
};
}
/**
* Aggregate ALL pages of `prompts/list` via the SDK's high-level
* cache-aware `client.listPrompts()`. See {@link listAllTools} for the
* `cacheMode` semantics; this is the path the managed prompt list uses on
* refresh.
*/
async listAllPrompts(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
}): Promise<{ prompts: Prompt[] }> {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(() =>
this.client!.listPrompts(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
);
return { prompts: [...response.prompts] };
}
/**
* Get a prompt by name
* @param name Prompt name
* @param args Optional prompt arguments
* @param metadata Optional metadata to include in the request
* @returns Prompt content
*/
async getPrompt(
name: string,
args?: Record<string, JsonValue>,
metadata?: Record<string, string>,
): Promise<PromptGetInvocation> {
if (!this.client) {
throw new Error("Client is not connected");
}
// Convert all arguments to strings for prompt arguments
const stringArgs = args ? convertPromptArguments(args) : {};
const effectiveMeta = this.mergeMeta(metadata);
const params: GetPromptRequest["params"] = {
name,
arguments: stringArgs,
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
// MRTR-driven (#1704): a modern `prompts/get` can return `input_required`;
// the driver pauses at the pending-request UI and retries with the answer.
// Legacy is a single round.
const result = await this.invokeMcpClient(
() =>
this.requestWithInputRequired(
"prompts/get",
params,
GetPromptResultSchema,
this.getRequestOptions(metadata?.progressToken),
),
{ method: "prompts/get", toolName: name },
);
const invocation: PromptGetInvocation = {
result,
timestamp: new Date(),
name,
params: Object.keys(stringArgs).length > 0 ? stringArgs : undefined,
metadata: effectiveMeta,
};
this.dispatchTypedEvent("promptContentChange", {
name,
content: invocation,
timestamp: invocation.timestamp,
});
return invocation;
}
/**
* Request completions for a resource template variable or prompt argument
* @param ref Resource template reference or prompt reference
* @param argumentName Name of the argument/variable to complete
* @param argumentValue Current (partial) value of the argument
* @param context Optional context with other argument values
* @param metadata Optional metadata to include in the request
* @returns Completion result with values array
* @throws Error if client is not connected or request fails (except MethodNotFound)
*/
async getCompletions(
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context?: Record<string, string>,
metadata?: Record<string, string>,
): Promise<{ values: string[]; total?: number; hasMore?: boolean }> {
if (!this.client) {
return { values: [] };
}
try {
const effectiveMeta = this.mergeMeta(metadata);
const params: CompleteRequest["params"] = {
ref,
argument: {
name: argumentName,
value: argumentValue,
},
...(context ? { context: { arguments: context } } : {}),
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
const response = await this.invokeMcpClient(
() =>
this.client!.complete(
params,
this.getRequestOptions(metadata?.progressToken),
),
{
method: "completion/complete",
toolName: ref.type === "ref/prompt" ? ref.name : ref.uri,
},
);
return {
values: response.completion.values || [],
total: response.completion.total,
hasMore: response.completion.hasMore,
};
} catch (error) {
// Handle MethodNotFound gracefully (server doesn't support completions)
if (
(error instanceof ProtocolError &&
error.code === ProtocolErrorCode.MethodNotFound) ||
(error instanceof Error &&
(error.message.includes("Method not found") ||
error.message.includes("does not support completions")))
) {
return { values: [] };
}
// Re-throw other errors
throw new Error(
`Failed to get completions: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Fetch server info (capabilities, serverInfo, instructions) from cached initialize response
* This does not send any additional MCP requests - it just reads cached data
* Always called on connect
*/
private async fetchServerInfo(): Promise<void> {
if (!this.client) {
return;
}
try {
// Get server capabilities (cached from initialize response)
this.capabilities = this.client.getServerCapabilities();
this.dispatchTypedEvent("capabilitiesChange", this.capabilities);
// Get server info (name, version) and instructions (cached from initialize response)
this.serverInfo = this.client.getServerVersion();
this.instructions = this.client.getInstructions();
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
if (this.instructions !== undefined) {
this.dispatchTypedEvent("instructionsChange", this.instructions);
}
// Era model (SEP §7.8): the SDK Client owns negotiation and exposes the
// outcome. `getProtocolEra()` is populated for every era once connected —
// a plain legacy connect reports `"legacy"`. `getDiscoverResult()` is
// populated only when "auto"/"modern" actually probed server/discover.
this.protocolEra = this.client.getProtocolEra();
this.dispatchTypedEvent("protocolEraChange", this.protocolEra);
this.discoverResult = this.client.getDiscoverResult();
this.dispatchTypedEvent("discoverResultChange", this.discoverResult);
// The SDK's negotiated-version accessor works for both eras (the
// initialize handshake on legacy, the discover/pin on modern), so it
// supersedes the older MessageTrackingTransport capture.
this.protocolVersion = this.client.getNegotiatedProtocolVersion();
this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion);
} catch {
// Ignore errors in fetching server info
}
}
private dispatchStderrLog(entry: StderrLogEntry): void {
this.dispatchTypedEvent("stderrLog", entry);
}
private dispatchFetchRequest(entry: FetchRequestEntry): void {
this.logger.info(
{
component: "InspectorClient",
category: entry.category,
fetchRequest: {
url: entry.url,
method: entry.method,
headers: entry.requestHeaders,
body: entry.requestBody ?? "[no body]",
},
fetchResponse: entry.error
? { error: entry.error }
: {
status: entry.responseStatus,
statusText: entry.responseStatusText,
headers: entry.responseHeaders,
body: entry.responseBody,
},
},
`${entry.category} fetch`,
);
this.dispatchTypedEvent("fetchRequest", entry);
}
private dispatchFetchRequestBodyUpdate(
id: string,
responseBody: string,
): void {
this.dispatchTypedEvent("fetchRequestBodyUpdate", { id, responseBody });
}
/**
* Get current session ID (from OAuth state authId)
*/
getSessionId(): string | undefined {
return this.sessionId;
}
/**
* Set session ID (typically extracted from OAuth state)
*/
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
}
/**
* Dispatch saveSession so FetchRequestLogState (or other listeners) can persist.
* Call before OAuth redirect; listeners use sessionStorage with this sessionId.
*/
saveSession(): void {
if (!this.sessionId) return;
this.dispatchTypedEvent("saveSession", { sessionId: this.sessionId });
}
/**
* Get current roots
*/
getRoots(): Root[] {
return this.roots !== undefined ? [...this.roots] : [];
}
/**
* Set roots and announce the change to the server.
*
* Note this does **not** enable the roots capability on a client that was
* built without the constructor's `roots` option. `capabilities.roots` is
* negotiated at `initialize` and the SDK refuses `registerCapabilities`
* after connect, so such a client has no `roots/list` handler (see {@link
* registerPeerRequestHandlers}) and would have to answer `-32601` if a
* server asked. So on such a client the roots set here are stored and
* readable via {@link getRoots}, but no server can ask for them and the
* change is not announced — the SDK refuses `roots/list_changed` from a
* client that never declared `roots.listChanged`, so the notification could
* not have gone out anyway. Pass `roots` at construction — `[]` is enough —
* in any client that may call this (#1797).
*
* The argument runs through `cleanRoots`, the same normalizer the
* connect-time and settings-save paths use, so all three ways roots enter the
* client agree and no caller can advertise a `Root` with no `uri` (the CLI's
* `--roots-json` only checks that the JSON is an array). It is idempotent, so
* a caller that already cleaned loses nothing.
*/
async setRoots(roots: Root[]): Promise<void> {
if (!this.client) {
throw new Error("Client is not connected");
}
this.roots = cleanRoots(roots);
// Copy, as `getRoots()` does — a listener must not be able to push into the
// list we advertise.
this.dispatchTypedEvent("rootsChange", [...this.roots]);
// The *server* needn't advertise support for this notification, but the
// *client* must have declared `roots.listChanged` to send it. The SDK
// enforces that itself — `notification()` rejects with "Client does not
// support roots list changed notifications" — so nothing reaches the wire
// on a client built without `roots`, and the server is never invited to
// re-fetch something we'd answer `-32601`. Returning early only avoids
// provoking that rejection and logging it as a *failure*: it isn't one, it
// is a client that was never able to announce (#1797).
if (!this.rootsListChangedCapabilityAdvertised) {
this.logger.warn(
"setRoots() on a client that did not advertise `roots.listChanged`; " +
"roots are stored locally but the change is not announced",
);
return;
}
try {
await this.client.notification({
method: "notifications/roots/list_changed",
});
} catch (error) {
// Log but don't throw - roots were updated locally even if notification failed
this.logger.error(
{ error },
"Failed to send roots/list_changed notification",
);
}
}
/**
* Get list of currently subscribed resource URIs
*/
getSubscribedResources(): string[] {
return Array.from(this.subscribedResources);
}
/**
* Check if a resource is currently subscribed
*/
isSubscribedToResource(uri: string): boolean {
return this.subscribedResources.has(uri);
}
/**
* Check if the server supports resource subscriptions
*/
supportsResourceSubscriptions(): boolean {
return this.capabilities?.resources?.subscribe === true;
}
/**
* The negotiated protocol era once connected (SEP §7.8). Modern (2026-07-28)
* connections manage resource subscriptions through a `subscriptions/listen`
* stream instead of `resources/subscribe`; every other era is legacy.
*/
private isModernEra(): boolean {
return this.protocolEra === "modern";
}
/**
* Current state of the modern-era `subscriptions/listen` stream (#1630).
* `active: false` on the legacy era (there is no persistent stream).
*/
getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState {
return this.modernStreamState;
}
private setModernStreamState(next: ResourceSubscriptionStreamState): void {
this.modernStreamState = next;
this.dispatchTypedEvent("resourceSubscriptionStreamChange", next);
}
private dispatchSubscriptionsChange(): void {
this.dispatchTypedEvent(
"resourceSubscriptionsChange",
Array.from(this.subscribedResources),
);
}
/**
* The `subscriptions/listen` filter for the current modern subscriptions:
* the subscribed URIs, plus the list-change opt-ins the Inspector already
* tracks (config ∩ server capability) so the single stream also carries
* list-change notifications — the spec models one listen stream for every
* opted-in notification type (SEP §7.4).
*/
private buildSubscriptionFilter(): SubscriptionFilter {
const filter: SubscriptionFilter = {
resourceSubscriptions: Array.from(this.subscribedResources),
};
if (
this.listChangedNotifications.tools &&
this.capabilities?.tools?.listChanged
) {
filter.toolsListChanged = true;
}
if (
this.listChangedNotifications.resources &&
this.capabilities?.resources?.listChanged
) {
filter.resourcesListChanged = true;
}
if (
this.listChangedNotifications.prompts &&
this.capabilities?.prompts?.listChanged
) {
filter.promptsListChanged = true;
}
return filter;
}
/** Cancel a pending reconnect re-listen, if any (#1630). */
private clearModernReconnectTimer(): void {
if (this.modernReconnectTimer !== undefined) {
clearTimeout(this.modernReconnectTimer);
this.modernReconnectTimer = undefined;
}
}
/**
* (Re-)establish the modern `subscriptions/listen` stream to match the current
* `subscribedResources` set (#1630). Because the stream is not resumable,
* every filter change re-lists: the existing stream is closed and a fresh
* `listen()` opened. With no subscribed URIs the stream is left closed.
*
* `modernListenGeneration` guards against races — if a newer refresh starts
* while this one awaits its acknowledgement, the just-opened stream is
* discarded rather than overwriting the newer one.
*/
private async refreshModernSubscription(
fromReconnect = false,
): Promise<void> {
if (!this.client) return;
// A user-initiated (subscribe/unsubscribe) refresh is a clean slate: clear
// any pending reconnect and reset the backoff run so the next drop starts
// from the base delay.
if (!fromReconnect) {
this.clearModernReconnectTimer();
this.modernReconnectAttempts = 0;
}
const generation = ++this.modernListenGeneration;
// Tear down the current stream before opening a replacement (re-listen).
const previous = this.modernSubscription;
this.modernSubscription = null;
if (previous) {
await closeSubscriptionBestEffort(previous);
}
// Nothing subscribed → keep the stream closed.
if (this.subscribedResources.size === 0) {
this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
return;
}
const subscription = await this.client.listen(
this.buildSubscriptionFilter(),
this.getRequestOptions(),
);
// A newer refresh superseded us while awaiting the ack — discard this one.
if (generation !== this.modernListenGeneration) {
await closeSubscriptionBestEffort(subscription);
return;
}
this.modernSubscription = subscription;
// A successful acknowledgement ends any reconnect run: the backoff counts
// only *consecutive* failed re-lists, so a stream that recovers and holds
// starts fresh next time (#1630).
this.modernReconnectAttempts = 0;
this.setModernStreamState({
active: true,
status: "acknowledged",
honoredUris: subscription.honoredFilter.resourceSubscriptions ?? [],
});
// Observe termination; an unexpected drop reconnects by re-listing.
void subscription.closed.then(
(reason) =>
this.onModernSubscriptionClosed(subscription, reason, generation),
// The rejection arm exists because a `closed` that rejects carries no
// reason to act on, and an unhandled rejection ends a Node process by
// default. It is scoped to the rejection rather than chained after the
// handler because the handler cannot throw today — it only assigns state,
// dispatches on a native `EventTarget` (which reports listener throws
// rather than propagating them) and arms a timer — and chaining a
// `.catch` after it would silently abandon a re-listen if that ever
// changed. (Closing a stream *resolves* `closed`; what the connect-path
// close newly reaches is the handler running at all, where the reference
// used to be dropped with `closed` pending forever.)
() => {},
);
}
/**
* Handle termination of a modern listen stream (#1630). `"remote"` is an
* unexpected drop — reconnect by re-listing (no resumability, so the re-listen
* re-establishes the full filter). `"local"` (we closed it) and `"graceful"`
* (server shutdown) are expected and leave the stream ended.
*/
private onModernSubscriptionClosed(
subscription: McpSubscription,
reason: "local" | "graceful" | "remote",
generation: number,
): void {
// Ignore a superseded stream (a newer refresh already replaced it).
if (
generation !== this.modernListenGeneration ||
this.modernSubscription !== subscription
) {
return;
}
this.modernSubscription = null;
const shouldReconnect =
reason === "remote" &&
!isTerminalStatus(this.status) &&
this.subscribedResources.size > 0;
if (!shouldReconnect) {
// "stream gone but subscriptions remain" renders the same whether we gave
// up after failed reconnects or the server closed it gracefully: keep the
// ended badge while URIs are still subscribed. (A `disconnect()` clears
// the set and forces the inactive state; a *crash* leaves both in place
// until the next `connect()` calls `resetSubscriptionStream`, which moves
// them together — so "active with an empty set" is never observable.)
this.setModernStreamState({
active: this.subscribedResources.size > 0,
status: "ended",
honoredUris: [],
});
return;
}
// A drop of an established stream is not itself a failure — schedule a
// re-listen at the current backoff (0 after a healthy stream, so the base
// delay). The counter only advances when a re-listen actually fails.
this.scheduleModernReconnect();
}
/**
* Reconcile the stream state after a re-listen *this* call owned and lost
* (#1797). Only correct for a caller that has not been superseded — a newer
* refresh owns the state as well as the filter — so both call sites gate on
* the generation first.
*
* The empty case is the ordinary one: nothing subscribed, no stream, inactive.
* The non-empty one exists because a failed re-listen leaves
* `modernSubscription` null with URIs still subscribed, and nothing else will
* notice: the reconnect machinery is reachable only from a stream that closed
* or a reconnect that failed, and neither happened here. Left alone, the state
* keeps whatever the last success (or the optimistic `"connecting"`) wrote — a
* badge that will never change over subscriptions the server may never have
* honored, recoverable only by an Unsubscribe/Subscribe toggle (a fresh
* Subscribe early-returns on the URI already being in the set).
*
* So it reconnects rather than settling for an honest-but-dead `"ended"`:
* every other route to "stream gone, URIs live" either expects the close or
* has exhausted the retry cap, and this is the one that has made no attempt
* at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh
* already reset `modernReconnectAttempts`, so it starts at the base delay; the
* timer bails on a terminal status or an emptied set; and past the cap
* `onModernReconnectFailed` lands on the same `"ended"` badge. The state
* therefore becomes true or ends after a real attempt. The caller still sees
* its error either way — the retry is about the subscriptions, not the call.
*/
private reconcileModernStreamStateAfterFailedRefresh(): void {
if (this.subscribedResources.size === 0) {
this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
return;
}
this.scheduleModernReconnect();
}
/**
* Schedule a reconnect re-listen after the current backoff delay (#1630).
* `modernReconnectAttempts` reflects the number of *consecutive failed*
* re-lists (reset to 0 on any successful acknowledgement), so the delay grows
* only while re-listing keeps failing.
*/
private scheduleModernReconnect(): void {
this.setModernStreamState({
active: true,
status: "reconnecting",
honoredUris: [],
});
const delay = Math.min(
MODERN_RECONNECT_BASE_MS * 2 ** this.modernReconnectAttempts,
MODERN_RECONNECT_MAX_MS,
);
this.clearModernReconnectTimer();
this.modernReconnectTimer = setTimeout(() => {
this.modernReconnectTimer = undefined;
// Disconnect/unsubscribe may have raced the timer — bail if the reconnect
// is no longer wanted.
if (
isTerminalStatus(this.status) ||
this.subscribedResources.size === 0
) {
return;
}
this.refreshModernSubscription(true).catch(() =>
this.onModernReconnectFailed(),
);
}, delay);
}
/**
* A reconnect re-listen failed (#1630). Count it and either retry with a
* longer backoff or, past the consecutive-failure cap, give up and mark the
* stream ended (re-subscribing resets the run and tries again).
*/
private onModernReconnectFailed(): void {
this.modernReconnectAttempts += 1;
if (
this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS ||
isTerminalStatus(this.status) ||
this.subscribedResources.size === 0
) {
this.setModernStreamState({
active: this.subscribedResources.size > 0,
status: "ended",
honoredUris: [],
});
return;
}
this.scheduleModernReconnect();
}
/**
* Subscribe to a resource to receive update notifications.
*
* Legacy era: sends `resources/subscribe`. Modern era (2026-07-28): adds the
* URI to the `subscriptions/listen` filter and re-lists (#1630). In both eras
* `notifications/resources/updated` is delivered through the same handler.
*
* @param uri - The URI of the resource to subscribe to
* @throws Error if client is not connected or server doesn't support subscriptions
*/
async subscribeToResource(uri: string): Promise<void> {
if (!this.client) {
throw new Error("Client is not connected");
}
if (!this.supportsResourceSubscriptions()) {
throw new Error("Server does not support resource subscriptions");
}
try {
if (this.isModernEra()) {
// Already subscribed → the filter is unchanged, so skip the re-listen
// (which would needlessly tear down and reopen the server stream).
if (this.subscribedResources.has(uri)) return;
this.subscribedResources.add(uri);
// Reflect the subscription optimistically so the UI responds to the
// click immediately, and show the stream as "connecting" until the
// `listen()` acknowledgement lands (which can be a visible round-trip on
// the modern era, unlike the single-shot legacy `resources/subscribe`).
this.dispatchSubscriptionsChange();
this.setModernStreamState({
active: true,
status: "connecting",
honoredUris: this.modernStreamState.honoredUris,
});
// Read before the call, to tell "our refresh failed" from "someone else
// took over" in the catch — see there.
const generationBefore = this.modernListenGeneration;
try {
await this.refreshModernSubscription();
} catch (error) {
// Roll back the optimistic add + stream state so both stay consistent
// with the server filter — but only while this call still owns that
// filter. A refresh bumps the generation exactly once, synchronously,
// so anything past that bump means something else advanced it, and
// both bumpers make the rollback wrong:
//
// - a newer `refreshModernSubscription` built its filter from the set
// *including* this URI, so if it succeeded the server is honoring
// the subscription; deleting it here would leave the set missing a
// URI the live stream carries, the UI showing it unsubscribed while
// its `resources/updated` keep arriving.
// - `resetSubscriptionStream` (a `disconnect()`, or the start-clean
// reset in `connect()`) already cleared the set and set the state,
// so there is nothing to roll back — and if the caller has since
// re-subscribed on the new session, this stale catch would delete a
// URI that session legitimately holds.
//
// Either way the state is no longer ours to correct. The error is
// still the caller's to see.
if (this.modernListenGeneration === generationBefore + 1) {
// State before the announce, for `resetSubscriptionStream`'s
// reason: on a last-URI rollback, dispatching first would expose an
// empty set with a stream still reading `"connecting"` — the pair
// this file treats as impossible. The reverse intermediate is the
// benign one (and the optimistic add above already exposes it).
this.subscribedResources.delete(uri);
this.reconcileModernStreamStateAfterFailedRefresh();
this.dispatchSubscriptionsChange();
}
throw error;
}
return;
}
await this.client.subscribeResource({ uri }, this.getRequestOptions());
this.subscribedResources.add(uri);
this.dispatchSubscriptionsChange();
} catch (error) {
throw new Error(
`Failed to subscribe to resource: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Unsubscribe from a resource.
*
* Legacy era: sends `resources/unsubscribe`. Modern era: drops the URI from
* the `subscriptions/listen` filter and re-lists (closing the stream once the
* last URI is removed) (#1630).
*
* @param uri - The URI of the resource to unsubscribe from
* @throws Error if client is not connected
*/
async unsubscribeFromResource(uri: string): Promise<void> {
if (!this.client) {
throw new Error("Client is not connected");
}
try {
if (this.isModernEra()) {
// Not subscribed → the filter is unchanged, so skip the re-listen.
if (!this.subscribedResources.delete(uri)) return;
// The removal is the user's intent; keep it even if the re-listen fails
// (the stale URI simply lingers in the server's honored filter).
//
// Removing the last URI moves the stream to inactive before announcing
// the set, rather than letting the re-listen below do it a round-trip
// later: dispatching first would expose an empty set with an `active`
// stream, which is the pair `resetSubscriptionStream` orders its own
// writes to prevent. The re-listen sets the same state on arrival, and
// sets it on the failure path too (see the catch).
if (this.subscribedResources.size === 0) {
this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
}
this.dispatchSubscriptionsChange();
// Same ownership test as `subscribeToResource` — see the long comment
// there. Nothing to undo on this path (the removal is kept
// deliberately), but the *state* still needs reconciling when this call
// owned the re-listen that failed: it leaves no stream behind, and the
// badge would otherwise keep reporting the last success.
const generationBefore = this.modernListenGeneration;
try {
await this.refreshModernSubscription();
} catch (error) {
if (this.modernListenGeneration === generationBefore + 1) {
this.reconcileModernStreamStateAfterFailedRefresh();
}
throw error;
}
} else {
await this.client.unsubscribeResource(
{ uri },
this.getRequestOptions(),
);
this.subscribedResources.delete(uri);
this.dispatchSubscriptionsChange();
}
} catch (error) {
throw new Error(
`Failed to unsubscribe from resource: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// ============================================================================
// OAuth Support (delegated to oauthManager)
// ============================================================================
private ensureOAuthManager(): OAuthManager {
if (!this.oauthManager) {
throw new Error("OAuth not configured. Call setOAuthConfig() first.");
}
return this.oauthManager;
}
/**
* Get server URL from transport config (full URL including path, for OAuth discovery)
*/
private getServerUrl(): string {
if (
this.transportConfig.type === "sse" ||
this.transportConfig.type === "streamable-http"
) {
return this.transportConfig.url;
}
// Stdio transports don't have a URL - OAuth not applicable
throw new Error(
"OAuth is only supported for HTTP-based transports (SSE, streamable-http)",
);
}
/**
* Set OAuth configuration
*/
setOAuthConfig(config: {
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
scope?: string;
}): void {
if (!this.oauthManager) {
throw new Error(
"OAuth config must be set at creation. Pass oauth in constructor.",
);
}
this.oauthManager.setOAuthConfig(config);
}
/**
* Initiates OAuth flow. Can be called directly by user or automatically
* triggered by 401 errors.
*/
async authenticate(): Promise<URL | undefined> {
return this.ensureOAuthManager().authenticate();
}
/**
* Satisfy a mid-session auth challenge (token refresh, step-up, or interactive re-auth).
*/
async handleAuthChallenge(
challenge: AuthChallenge,
options?: HandleAuthChallengeOptions,
): Promise<AuthChallengeOutcome> {
return this.ensureOAuthManager().handleAuthChallenge(challenge, options);
}
/**
* Re-read OAuth storage and test whether a challenge is already satisfied.
* See {@link OAuthManager.checkAuthChallengeSatisfied}.
*/
async checkAuthChallengeSatisfied(
challenge: AuthChallenge,
): Promise<boolean> {
return this.ensureOAuthManager().checkAuthChallengeSatisfied(challenge);
}
/**
* Push recovered OAuth auth state to the remote backend (same MCP session).
*/
async pushRemoteAuthState(): Promise<void> {
if (!(this.baseTransport instanceof RemoteClientTransport)) {
return;
}
await this.baseTransport.pushAuthState();
}
/**
* Handle an ambient (SSE) auth challenge when no command-scoped send is active.
* Recovers session tokens on the remote backend; does not retry RPCs.
*/
async handleAmbientAuthChallenge(challenge: AuthChallenge): Promise<void> {
const key = this.ambientAuthChallengeKey(challenge);
const existing = this.ambientAuthChallengeInFlight.get(key);
if (existing) {
return existing;
}
const promise = this.runAmbientAuthChallenge(challenge);
this.ambientAuthChallengeInFlight.set(key, promise);
try {
await promise;
} finally {
if (this.ambientAuthChallengeInFlight.get(key) === promise) {
this.ambientAuthChallengeInFlight.delete(key);
}
}
}
private async runAmbientAuthChallenge(
challenge: AuthChallenge,
): Promise<void> {
try {
this.dispatchTypedEvent("authChallengeAmbient", { challenge });
const oauthManager = this.oauthManager;
if (!oauthManager) {
return;
}
const outcome = await oauthManager.handleAuthChallenge(challenge);
if (outcome.kind === "satisfied") {
if (this.baseTransport instanceof RemoteClientTransport) {
await this.pushRemoteAuthState();
} else {
await this.reconnectAfterAuthRecovery();
}
this.dispatchTypedEvent("authChallengeRecovered", { challenge });
} else if (outcome.kind === "step_up_confirm") {
this.dispatchTypedEvent("authChallengeInteractive", {
challenge: outcome.challenge,
authorizationUrl: EMA_STEP_UP_PENDING_URL,
});
} else if (outcome.kind === "interactive") {
this.dispatchTypedEvent("authChallengeInteractive", {
challenge: outcome.challenge,
authorizationUrl: outcome.authorizationUrl,
});
} else {
this.dispatchTypedEvent("oauthError", { error: outcome.error });
}
} catch (error) {
this.dispatchTypedEvent("oauthError", {
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
private ambientAuthChallengeKey(challenge: AuthChallenge): string {
const requiredScopes = [...(challenge.requiredScopes ?? [])]
.sort()
.join(" ");
const authorizationScopes = [...(challenge.authorizationScopes ?? [])]
.sort()
.join(" ");
return `${challenge.reason}:${requiredScopes}:${authorizationScopes}`;
}
/**
* Full disconnect + reconnect after ambient auth recovery on direct transports.
*/
private async reconnectAfterAuthRecovery(): Promise<void> {
await this.disconnect().catch(() => {});
await this.dropCachedTransport();
await this.connect();
}
/** Direct (non-remote) OAuth transports recover via fetch intercept + handleAuthChallenge. */
private usesDirectAuthRecovery(): boolean {
return this.directAuthRecovery && this.directAuthRecoveryActive === true;
}
private async withDirectAuthRecovery<T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
attempt = 0,
): Promise<T> {
try {
return await operation();
} catch (err) {
if (attempt >= 1 || !this.usesDirectAuthRecovery()) {
throw err;
}
if (!isAuthChallengeError(err)) {
throw err;
}
const challenge = parseAuthChallengeFromError(err, context);
/* v8 ignore next 3 -- defensive: parseAuthChallengeFromError shares isAuthChallengeError's checks, so it always returns a truthy challenge once that guard passes */
if (!challenge) {
throw err;
}
if (context?.method || context?.toolName) {
this.dispatchTypedEvent("authChallengeCommand", { challenge });
} else {
this.dispatchTypedEvent("authChallengeAmbient", { challenge });
}
const outcome = await this.handleAuthChallenge(challenge);
if (outcome.kind === "satisfied") {
// Reconnect aborts activeToolCallAbortController; clear it so callTool
// retries are not immediately rejected with "Disconnected".
if (this.activeToolCallAbortController) {
this.activeToolCallAbortController = undefined;
}
await this.reconnectAfterAuthRecovery();
this.dispatchTypedEvent("authChallengeRecovered", { challenge });
return this.withDirectAuthRecovery(operation, context, attempt + 1);
}
if (outcome.kind === "step_up_confirm") {
throw new AuthRecoveryRequiredError(
EMA_STEP_UP_PENDING_URL,
outcome.challenge,
{ emaStepUpConfirm: true },
);
}
if (outcome.kind === "interactive") {
throw new AuthRecoveryRequiredError(
outcome.authorizationUrl,
outcome.challenge,
);
}
this.dispatchTypedEvent("oauthError", { error: outcome.error });
throw outcome.error;
}
}
private async invokeMcpClient<T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
): Promise<T> {
if (!this.usesDirectAuthRecovery()) {
return operation();
}
return this.withDirectAuthRecovery(operation, context);
}
/**
* Completes OAuth flow with authorization code from the redirect callback.
* Direct transports reconnect after token exchange so the live MCP session
* picks up the new Bearer token (mirrors silent recovery reconnect).
*/
async completeOAuthFlow(
authorizationCode: string,
iss?: string,
): Promise<void> {
await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss);
if (this.usesDirectAuthRecovery()) {
await this.reconnectAfterAuthRecovery();
}
}
/**
* Navigate to the authorization server for interactive recovery.
*/
async beginInteractiveAuthorization(authorizationUrl: URL): Promise<void> {
return this.ensureOAuthManager().beginInteractiveAuthorization(
authorizationUrl,
);
}
/** Remote Hono session id when using {@link RemoteClientTransport}. */
getRemoteBackendSessionId(): string | undefined {
if (this.baseTransport instanceof RemoteClientTransport) {
return this.baseTransport.getRemoteBackendSessionId();
}
return undefined;
}
/**
* Finish OAuth after a full-page redirect and reconnect (or reattach) the MCP session.
*/
async resumeAfterOAuth(
authorizationCode: string,
options?: { remoteSessionId?: string; iss?: string },
): Promise<void> {
await this.completeOAuthFlow(authorizationCode, options?.iss);
const remoteSessionId = options?.remoteSessionId;
const transport = this.baseTransport;
if (remoteSessionId && transport instanceof RemoteClientTransport) {
try {
await transport.attachToSession(remoteSessionId);
await transport.pushAuthState();
if (this.status !== "connected") {
await this.connect();
}
return;
} catch {
// Session expired during OAuth round trip — fall back to fresh connect.
}
}
if (this.status !== "connected") {
await this.connect();
}
}
/**
* Gets current OAuth tokens (if authorized)
*/
async getOAuthTokens(): Promise<OAuthTokens | undefined> {
if (!this.oauthManager) {
return undefined;
}
return this.oauthManager.getOAuthTokens();
}
/**
* Clears OAuth tokens and client information
*/
async clearOAuthTokens(): Promise<void> {
await this.oauthManager?.clearOAuthTokens();
}
/**
* Checks if client is currently OAuth authorized
*/
async isOAuthAuthorized(): Promise<boolean> {
if (!this.oauthManager) {
return false;
}
return this.oauthManager.isOAuthAuthorized();
}
/**
* In-memory OAuth flow snapshot. Undefined when no flow has run on this
* client instance; use {@link getOAuthState} for persisted authorization state.
*/
getOAuthFlowState(): OAuthFlowState | undefined {
return this.oauthManager?.getOAuthFlowState();
}
/** Current step when an OAuth flow is active. */
getOAuthFlowStep(): OAuthStep | undefined {
return this.oauthManager?.getOAuthFlowStep();
}
/**
* Persisted OAuth authorization snapshot for this HTTP server (storage +
* config). Undefined for stdio transports or when OAuth is not configured.
*/
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
if (!this.isHttpOAuthConfig() || !this.oauthManager) {
return undefined;
}
return this.oauthManager.getOAuthState();
}
}