@modelcontextprotocol/inspector 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -83,9 +83,6 @@ function withConnectTimeout(settings, connectionTimeout) {
83
83
  };
84
84
  }
85
85
 
86
- // ../../core/auth/node/secret-store.ts
87
- import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring";
88
-
89
86
  // ../../core/auth/secret-fields.ts
90
87
  var SECRET_FIELD_OAUTH_CLIENT_SECRET = "oauth-client-secret";
91
88
  var SECRET_FIELD_IDP_CLIENT_SECRET = "idp-client-secret";
@@ -93,6 +90,41 @@ var envSecretField = (envKey) => `env:${envKey}`;
93
90
 
94
91
  // ../../core/auth/node/secret-store.ts
95
92
  var SERVICE_NAME = "mcp-inspector";
93
+ var keyringLoad;
94
+ var KeyringModuleShapeError = class extends Error {
95
+ constructor(detail, options) {
96
+ super(`@napi-rs/keyring loaded but ${detail}`, options);
97
+ this.name = "KeyringModuleShapeError";
98
+ }
99
+ };
100
+ var checkKeyringShape = (mod) => {
101
+ try {
102
+ if (typeof mod.AsyncEntry === "function" && typeof mod.findCredentialsAsync === "function") {
103
+ return { ok: true, mod };
104
+ }
105
+ return {
106
+ ok: false,
107
+ err: new KeyringModuleShapeError(
108
+ "did not expose AsyncEntry / findCredentialsAsync"
109
+ )
110
+ };
111
+ } catch (err) {
112
+ return {
113
+ ok: false,
114
+ err: new KeyringModuleShapeError(
115
+ `its exports could not be read: ${err instanceof Error ? err.message : String(err)}`,
116
+ { cause: err }
117
+ )
118
+ };
119
+ }
120
+ };
121
+ var loadKeyring = () => {
122
+ keyringLoad ??= import("@napi-rs/keyring").then(
123
+ checkKeyringShape,
124
+ (err) => ({ ok: false, err })
125
+ );
126
+ return keyringLoad;
127
+ };
96
128
  function parseAccount(account) {
97
129
  const idx = account.indexOf(":");
98
130
  if (idx <= 0 || idx === account.length - 1) return null;
@@ -104,16 +136,31 @@ function parseAccount(account) {
104
136
  var buildAccount = (serverId, field) => `${serverId}:${field}`;
105
137
  var KeychainUnavailableError = class extends Error {
106
138
  constructor(cause) {
139
+ const message = cause instanceof Error ? cause.message : String(cause);
107
140
  super(
108
- `OS keychain is not available. On Linux, install libsecret / gnome-keyring. Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`
141
+ `OS keychain is not available. ${hintFor(cause, message)} Underlying error: ${message}`
109
142
  );
110
143
  this.name = "KeychainUnavailableError";
111
144
  }
112
145
  };
146
+ var hintFor = (cause, message) => {
147
+ if (cause instanceof KeyringModuleShapeError) {
148
+ return `The @napi-rs/keyring package loaded but does not expose the API this build expects \u2014 most likely a version or packaging mismatch; reinstall the Inspector, and report this if it persists.`;
149
+ }
150
+ if (message.includes("Cannot find native binding")) {
151
+ return `The @napi-rs/keyring platform package for this OS is missing or unavailable \u2014 reinstall the Inspector (for npx, clear the npx cache under your npm cache directory first).`;
152
+ }
153
+ return `On Linux, install libsecret / gnome-keyring.`;
154
+ };
113
155
  var KeyringSecretStore = class {
114
156
  async get(serverId, field) {
115
- const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
116
157
  try {
158
+ const keyring = await loadKeyring();
159
+ if (!keyring.ok) return null;
160
+ const entry = new keyring.mod.AsyncEntry(
161
+ SERVICE_NAME,
162
+ buildAccount(serverId, field)
163
+ );
117
164
  const v2 = await entry.getPassword();
118
165
  return v2 ?? null;
119
166
  } catch {
@@ -121,16 +168,27 @@ var KeyringSecretStore = class {
121
168
  }
122
169
  }
123
170
  async set(serverId, field, value) {
124
- const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
125
171
  try {
172
+ const keyring = await loadKeyring();
173
+ if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
174
+ const entry = new keyring.mod.AsyncEntry(
175
+ SERVICE_NAME,
176
+ buildAccount(serverId, field)
177
+ );
126
178
  await entry.setPassword(value);
127
179
  } catch (err) {
180
+ if (err instanceof KeychainUnavailableError) throw err;
128
181
  throw new KeychainUnavailableError(err);
129
182
  }
130
183
  }
131
184
  async delete(serverId, field) {
132
- const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
133
185
  try {
186
+ const keyring = await loadKeyring();
187
+ if (!keyring.ok) return;
188
+ const entry = new keyring.mod.AsyncEntry(
189
+ SERVICE_NAME,
190
+ buildAccount(serverId, field)
191
+ );
134
192
  await entry.deleteCredential();
135
193
  } catch {
136
194
  }
@@ -138,7 +196,9 @@ var KeyringSecretStore = class {
138
196
  async deleteAllForServer(serverId) {
139
197
  let creds;
140
198
  try {
141
- creds = await findCredentialsAsync(SERVICE_NAME);
199
+ const keyring = await loadKeyring();
200
+ if (!keyring.ok) return;
201
+ creds = await keyring.mod.findCredentialsAsync(SERVICE_NAME);
142
202
  } catch {
143
203
  return;
144
204
  }
@@ -4629,6 +4689,7 @@ async function* parseSSE(reader) {
4629
4689
  }
4630
4690
  var RemoteClientTransport = class {
4631
4691
  _sessionId = void 0;
4692
+ _protocolVersion = void 0;
4632
4693
  eventStreamReader = null;
4633
4694
  eventStreamAbort = null;
4634
4695
  eventStreamConsumeTask = null;
@@ -4651,6 +4712,21 @@ var RemoteClientTransport = class {
4651
4712
  getRemoteBackendSessionId() {
4652
4713
  return this._sessionId;
4653
4714
  }
4715
+ /**
4716
+ * The SDK Client calls this with the negotiated protocol version as soon as
4717
+ * `initialize` resolves — before it sends `notifications/initialized` — so an
4718
+ * HTTP transport can stamp `Mcp-Protocol-Version` on every later request.
4719
+ *
4720
+ * Here the real upstream transport lives on the backend, so we can only
4721
+ * record the version and forward it on the next `/api/mcp/send`, which
4722
+ * applies it to the upstream transport *before* sending. Because
4723
+ * `notifications/initialized` is that next send, it and everything after it
4724
+ * (including the standalone SSE GET and the session DELETE, both issued by
4725
+ * the upstream transport itself) carry the header (#1935).
4726
+ */
4727
+ setProtocolVersion(version) {
4728
+ this._protocolVersion = version;
4729
+ }
4654
4730
  /**
4655
4731
  * Reattach to an existing remote backend session after a full-page OAuth
4656
4732
  * redirect. Opens the SSE event stream without POST /connect.
@@ -5020,7 +5096,12 @@ var RemoteClientTransport = class {
5020
5096
  },
5021
5097
  // Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to
5022
5098
  // apply to the upstream send; the browser can't set them cross-origin.
5023
- ...options?.headers != null && { headers: options.headers }
5099
+ ...options?.headers != null && { headers: options.headers },
5100
+ // Forward the negotiated protocol version so the backend's transport can
5101
+ // stamp `Mcp-Protocol-Version` on this and every later request (#1935).
5102
+ ...this._protocolVersion !== void 0 && {
5103
+ protocolVersion: this._protocolVersion
5104
+ }
5024
5105
  };
5025
5106
  const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
5026
5107
  method: "POST",
@@ -5101,6 +5182,13 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
5101
5182
  outputValidator = null;
5102
5183
  transport = null;
5103
5184
  baseTransport = null;
5185
+ // Correlation for `markResponseRejected` (#1953): the method of each
5186
+ // outbound request still awaiting a response, and — once one is answered —
5187
+ // the id of the most recently answered request per method. Entries are
5188
+ // dropped as responses arrive, so this holds at most one id per method
5189
+ // rather than growing with the session.
5190
+ outboundRequestMethods = /* @__PURE__ */ new Map();
5191
+ lastAnsweredRequestByMethod = /* @__PURE__ */ new Map();
5104
5192
  /** True when the cached transport was built with an OAuth authProvider attached. */
5105
5193
  transportHasAuthProvider = false;
5106
5194
  /** Dedupes concurrent ambient auth challenges (reason + scopes). */
@@ -5211,7 +5299,10 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
5211
5299
  // `McpSubscription` whose filter's `resourceSubscriptions` mirrors
5212
5300
  // `subscribedResources`; mutating the set re-lists (close old, open new), and
5213
5301
  // an unexpected `"remote"` close re-lists (reconnect-by-re-listen — the stream
5214
- // is not resumable). `null` when no URI is subscribed or on the legacy era.
5302
+ // is not resumable). `null` on the legacy era, and on the modern era whenever
5303
+ // the filter is empty — which is *not* the same as "no URI subscribed": the
5304
+ // stream also carries the list-change opt-ins, so a tools-only server opens it
5305
+ // with no subscriptions at all (#1920).
5215
5306
  modernSubscription = null;
5216
5307
  // Monotonic guard so a stale re-list/reconnect (whose `listen()` or `closed`
5217
5308
  // resolves after a newer refresh already started) can detect it lost the race
@@ -5434,6 +5525,9 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
5434
5525
  createMessageTrackingCallbacks() {
5435
5526
  return {
5436
5527
  trackRequest: (message, origin) => {
5528
+ if (origin === "client") {
5529
+ this.outboundRequestMethods.set(message.id, message.method);
5530
+ }
5437
5531
  const entry = {
5438
5532
  id: crypto.randomUUID(),
5439
5533
  timestamp: /* @__PURE__ */ new Date(),
@@ -5444,6 +5538,14 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
5444
5538
  this.dispatchTypedEvent("message", entry);
5445
5539
  },
5446
5540
  trackResponse: (message, origin) => {
5541
+ const responseId = message.id;
5542
+ if (origin === "server" && responseId !== void 0) {
5543
+ const method = this.outboundRequestMethods.get(responseId);
5544
+ this.outboundRequestMethods.delete(responseId);
5545
+ if (method !== void 0) {
5546
+ this.lastAnsweredRequestByMethod.set(method, responseId);
5547
+ }
5548
+ }
5447
5549
  const entry = {
5448
5550
  id: crypto.randomUUID(),
5449
5551
  timestamp: /* @__PURE__ */ new Date(),
@@ -6001,6 +6103,8 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
6001
6103
  this.clearReceiverTasks();
6002
6104
  this.resetSubscriptionStream();
6003
6105
  this.cancelledTaskIds.clear();
6106
+ this.outboundRequestMethods.clear();
6107
+ this.lastAnsweredRequestByMethod.clear();
6004
6108
  for (const [, controller] of this.taskInputAbortControllers) {
6005
6109
  controller.abort(new Error("Connection ended"));
6006
6110
  }
@@ -6224,7 +6328,6 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
6224
6328
  this.status = "connected";
6225
6329
  this.dispatchTypedEvent("statusChange", this.status);
6226
6330
  await this.fetchServerInfo();
6227
- this.dispatchTypedEvent("connect");
6228
6331
  if (this.initialLoggingLevel && this.capabilities?.logging) {
6229
6332
  await this.client.setLoggingLevel(
6230
6333
  this.initialLoggingLevel,
@@ -6303,6 +6406,10 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
6303
6406
  );
6304
6407
  }
6305
6408
  }
6409
+ await this.openModernListenStreamOnConnect();
6410
+ if (this.status === "connected" && !this.disconnecting) {
6411
+ this.dispatchTypedEvent("connect");
6412
+ }
6306
6413
  } catch (error) {
6307
6414
  if (!isConnectAuthRecoveryError(error)) {
6308
6415
  this.status = "error";
@@ -7114,7 +7221,11 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
7114
7221
  this.getCacheableRequestOptions(options?.cacheMode)
7115
7222
  )
7116
7223
  );
7117
- await this.refreshExcludedTools(options?.metadata).catch(() => {
7224
+ await this.refreshExcludedTools(options?.metadata).catch((err) => {
7225
+ this.logger.warn(
7226
+ { err },
7227
+ "Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete"
7228
+ );
7118
7229
  });
7119
7230
  return { tools: [...response.tools] };
7120
7231
  }
@@ -7127,6 +7238,26 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
7127
7238
  excludesInvalidXMcpHeaderTools() {
7128
7239
  return this.isModernEra() && this.getServerType() !== "stdio";
7129
7240
  }
7241
+ /**
7242
+ * Mark the response that most recently answered `method` as rejected by the
7243
+ * client, so its Protocol entry shows why instead of rendering as a clean
7244
+ * success (#1953).
7245
+ *
7246
+ * The SDK gives no request id with a decode failure — `SdkError` carries the
7247
+ * method and nothing else — so the id is recovered by correlation: the last
7248
+ * response received for that method. That is exact rather than approximate,
7249
+ * because the SDK rejects synchronously while decoding the response (inside
7250
+ * the transport's `onmessage`) and the caller's `catch` runs in the very next
7251
+ * microtask. Delivering another response for the same method in that window
7252
+ * would take a macrotask (a socket read), which cannot interleave there.
7253
+ *
7254
+ * A no-op when nothing has answered `method` this session.
7255
+ */
7256
+ markResponseRejected(method, reason) {
7257
+ const id = this.lastAnsweredRequestByMethod.get(method);
7258
+ if (id === void 0) return;
7259
+ this.dispatchTypedEvent("responseRejected", { id, reason });
7260
+ }
7130
7261
  /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */
7131
7262
  getExcludedTools() {
7132
7263
  return this.excludedTools;
@@ -8394,9 +8525,10 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8394
8525
  * opted-in notification type (SEP §7.4).
8395
8526
  */
8396
8527
  buildSubscriptionFilter() {
8397
- const filter = {
8398
- resourceSubscriptions: Array.from(this.subscribedResources)
8399
- };
8528
+ const filter = {};
8529
+ if (this.subscribedResources.size > 0) {
8530
+ filter.resourceSubscriptions = Array.from(this.subscribedResources);
8531
+ }
8400
8532
  if (this.listChangedNotifications.tools && this.capabilities?.tools?.listChanged) {
8401
8533
  filter.toolsListChanged = true;
8402
8534
  }
@@ -8408,6 +8540,74 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8408
8540
  }
8409
8541
  return filter;
8410
8542
  }
8543
+ /**
8544
+ * Whether the modern listen stream should be open: the built filter carries
8545
+ * something to listen for (#1920). Before #1920 this was "at least one URI is
8546
+ * subscribed", which made the stream unreachable on a server with no resources
8547
+ * — a tools-only server advertising `tools.listChanged` had no way to open it,
8548
+ * so `notifications/tools/list_changed` could never arrive. The filter already
8549
+ * modelled the list-change opt-ins; only the trigger was narrower than the
8550
+ * filter it built. This matches the SDK's own `ClientOptions.listChanged`
8551
+ * auto-open, which opens whenever the effective (config ∩ capability)
8552
+ * intersection is non-empty.
8553
+ *
8554
+ * Note this is deliberately *not* the same predicate as the stream state's
8555
+ * `active` — see `modernStreamActive()`.
8556
+ */
8557
+ wantsModernStream() {
8558
+ const filter = this.buildSubscriptionFilter();
8559
+ return (filter.resourceSubscriptions?.length ?? 0) > 0 || filter.toolsListChanged === true || filter.resourcesListChanged === true || filter.promptsListChanged === true;
8560
+ }
8561
+ /**
8562
+ * Whether the stream state reports `active` — i.e. whether the *Subscriptions*
8563
+ * UI has a stream to describe. That is a narrower question than
8564
+ * `wantsModernStream()`: `ResourceSubscriptionStreamState` drives the
8565
+ * Subscriptions section's badge, so a stream open purely for list-change
8566
+ * notifications (no subscribed URI) has nothing to report there and stays
8567
+ * `active: false` (#1920). Keeping the two apart also preserves the invariant
8568
+ * the rest of this file is written against — an empty subscribed set is never
8569
+ * announced alongside an `active` stream.
8570
+ */
8571
+ modernStreamActive() {
8572
+ return this.subscribedResources.size > 0;
8573
+ }
8574
+ /**
8575
+ * Open the modern listen stream at the end of a successful connect, when the
8576
+ * filter is non-empty (#1920). Only the list-change opt-ins can make it
8577
+ * non-empty here — the subscribed set is emptied by `resetSessionState()` on
8578
+ * the way in — so this is exactly the "server advertises a listChanged the
8579
+ * Inspector wants" case that had no trigger before.
8580
+ *
8581
+ * A failure is not allowed to fail the connect: the handshake succeeded and
8582
+ * every request-scoped feature works without this stream. It is reported the
8583
+ * way a lost stream is — hand it to the reconnect machinery, which retries
8584
+ * with backoff and settles on `"ended"` past the cap.
8585
+ *
8586
+ * Gated on the same generation test as `subscribeToResource` — see the long
8587
+ * comment there. The `connect` event has deliberately *not* been dispatched
8588
+ * yet (that is the point of running here), so a list-state consumer is not the
8589
+ * risk; what is, is anything else that bumps the generation while this
8590
+ * `listen()` is in flight. `statusChange` has already fired, and any
8591
+ * concurrent call on this instance qualifies: a `subscribeToResource` from a
8592
+ * caller restoring subscriptions, or a `disconnect()` — whose
8593
+ * `resetSubscriptionStream` bumps the generation too, making a reconcile here
8594
+ * arm a reconnect for a session that is already gone.
8595
+ */
8596
+ async openModernListenStreamOnConnect() {
8597
+ if (!this.isModernEra() || !this.wantsModernStream()) return;
8598
+ const generationBefore = this.modernListenGeneration;
8599
+ try {
8600
+ await this.refreshModernSubscription();
8601
+ } catch (error) {
8602
+ this.logger.error(
8603
+ { error },
8604
+ "Failed to open the modern subscriptions/listen stream on connect"
8605
+ );
8606
+ if (this.modernListenGeneration === generationBefore + 1) {
8607
+ this.reconcileModernStreamStateAfterFailedRefresh();
8608
+ }
8609
+ }
8610
+ }
8411
8611
  /** Cancel a pending reconnect re-listen, if any (#1630). */
8412
8612
  clearModernReconnectTimer() {
8413
8613
  if (this.modernReconnectTimer !== void 0) {
@@ -8417,9 +8617,10 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8417
8617
  }
8418
8618
  /**
8419
8619
  * (Re-)establish the modern `subscriptions/listen` stream to match the current
8420
- * `subscribedResources` set (#1630). Because the stream is not resumable,
8421
- * every filter change re-lists: the existing stream is closed and a fresh
8422
- * `listen()` opened. With no subscribed URIs the stream is left closed.
8620
+ * filter (#1630). Because the stream is not resumable, every filter change
8621
+ * re-lists: the existing stream is closed and a fresh `listen()` opened. With
8622
+ * an empty filter — no subscribed URIs *and* no enabled list-change opt-in the
8623
+ * server advertises — the stream is left closed (#1920).
8423
8624
  *
8424
8625
  * `modernListenGeneration` guards against races — if a newer refresh starts
8425
8626
  * while this one awaits its acknowledgement, the just-opened stream is
@@ -8437,7 +8638,7 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8437
8638
  if (previous) {
8438
8639
  await closeSubscriptionBestEffort(previous);
8439
8640
  }
8440
- if (this.subscribedResources.size === 0) {
8641
+ if (!this.wantsModernStream()) {
8441
8642
  this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
8442
8643
  return;
8443
8644
  }
@@ -8452,7 +8653,7 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8452
8653
  this.modernSubscription = subscription;
8453
8654
  this.modernReconnectAttempts = 0;
8454
8655
  this.setModernStreamState({
8455
- active: true,
8656
+ active: this.modernStreamActive(),
8456
8657
  status: "acknowledged",
8457
8658
  honoredUris: subscription.honoredFilter.resourceSubscriptions ?? []
8458
8659
  });
@@ -8483,10 +8684,10 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8483
8684
  return;
8484
8685
  }
8485
8686
  this.modernSubscription = null;
8486
- const shouldReconnect = reason === "remote" && !isTerminalStatus(this.status) && this.subscribedResources.size > 0;
8687
+ const shouldReconnect = reason === "remote" && !isTerminalStatus(this.status) && this.wantsModernStream();
8487
8688
  if (!shouldReconnect) {
8488
8689
  this.setModernStreamState({
8489
- active: this.subscribedResources.size > 0,
8690
+ active: this.modernStreamActive(),
8490
8691
  status: "ended",
8491
8692
  honoredUris: []
8492
8693
  });
@@ -8500,10 +8701,11 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8500
8701
  * refresh owns the state as well as the filter — so both call sites gate on
8501
8702
  * the generation first.
8502
8703
  *
8503
- * The empty case is the ordinary one: nothing subscribed, no stream, inactive.
8504
- * The non-empty one exists because a failed re-listen leaves
8505
- * `modernSubscription` null with URIs still subscribed, and nothing else will
8506
- * notice: the reconnect machinery is reachable only from a stream that closed
8704
+ * The two branches are the empty and non-empty *filter* (#1920) — which is
8705
+ * "nothing subscribed" only when no list-change opt-in is live. The empty case
8706
+ * is the ordinary one: nothing to listen for, no stream, inactive. The
8707
+ * non-empty one exists because a failed re-listen leaves `modernSubscription`
8708
+ * null with the filter still wanting a stream, and nothing else will notice: the reconnect machinery is reachable only from a stream that closed
8507
8709
  * or a reconnect that failed, and neither happened here. Left alone, the state
8508
8710
  * keeps whatever the last success (or the optimistic `"connecting"`) wrote — a
8509
8711
  * badge that will never change over subscriptions the server may never have
@@ -8511,17 +8713,17 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8511
8713
  * Subscribe early-returns on the URI already being in the set).
8512
8714
  *
8513
8715
  * So it reconnects rather than settling for an honest-but-dead `"ended"`:
8514
- * every other route to "stream gone, URIs live" either expects the close or
8716
+ * every other route to "stream gone, filter live" either expects the close or
8515
8717
  * has exhausted the retry cap, and this is the one that has made no attempt
8516
8718
  * at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh
8517
8719
  * already reset `modernReconnectAttempts`, so it starts at the base delay; the
8518
- * timer bails on a terminal status or an emptied set; and past the cap
8720
+ * timer bails on a terminal status or an emptied filter; and past the cap
8519
8721
  * `onModernReconnectFailed` lands on the same `"ended"` badge. The state
8520
8722
  * therefore becomes true or ends after a real attempt. The caller still sees
8521
8723
  * its error either way — the retry is about the subscriptions, not the call.
8522
8724
  */
8523
8725
  reconcileModernStreamStateAfterFailedRefresh() {
8524
- if (this.subscribedResources.size === 0) {
8726
+ if (!this.wantsModernStream()) {
8525
8727
  this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
8526
8728
  return;
8527
8729
  }
@@ -8535,7 +8737,7 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8535
8737
  */
8536
8738
  scheduleModernReconnect() {
8537
8739
  this.setModernStreamState({
8538
- active: true,
8740
+ active: this.modernStreamActive(),
8539
8741
  status: "reconnecting",
8540
8742
  honoredUris: []
8541
8743
  });
@@ -8546,7 +8748,7 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8546
8748
  this.clearModernReconnectTimer();
8547
8749
  this.modernReconnectTimer = setTimeout(() => {
8548
8750
  this.modernReconnectTimer = void 0;
8549
- if (isTerminalStatus(this.status) || this.subscribedResources.size === 0) {
8751
+ if (isTerminalStatus(this.status) || !this.wantsModernStream()) {
8550
8752
  return;
8551
8753
  }
8552
8754
  this.refreshModernSubscription(true).catch(
@@ -8561,9 +8763,9 @@ var InspectorClient = class _InspectorClient extends InspectorClientEventTarget
8561
8763
  */
8562
8764
  onModernReconnectFailed() {
8563
8765
  this.modernReconnectAttempts += 1;
8564
- if (this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS || isTerminalStatus(this.status) || this.subscribedResources.size === 0) {
8766
+ if (this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS || isTerminalStatus(this.status) || !this.wantsModernStream()) {
8565
8767
  this.setModernStreamState({
8566
- active: this.subscribedResources.size > 0,
8768
+ active: this.modernStreamActive(),
8567
8769
  status: "ended",
8568
8770
  honoredUris: []
8569
8771
  });
@@ -9209,13 +9411,22 @@ async function consumeMethodOutcome(outcome, args) {
9209
9411
  }
9210
9412
 
9211
9413
  // ../../core/mcp/state/managedListState.ts
9414
+ import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
9212
9415
  var DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250;
9416
+ function isClientDecodeRejection(err) {
9417
+ return SdkError.isInstance(err) && (err.code === SdkErrorCode.InvalidResult || err.code === SdkErrorCode.UnsupportedResultType);
9418
+ }
9213
9419
  var ManagedListState = class extends TypedEventTarget {
9214
9420
  items = [];
9215
9421
  client = null;
9216
9422
  unsubscribe = null;
9217
9423
  _metadata = void 0;
9218
9424
  listChanged = false;
9425
+ // The last fetch's failure, kept as observable state so a load that fails
9426
+ // (a transport error, or a result the SDK codec rejects as invalid) is
9427
+ // rendered by the list panel instead of vanishing into an unhandled
9428
+ // rejection — which read as "this server has no tools" (#1953).
9429
+ error = null;
9219
9430
  config;
9220
9431
  // Debounce a burst of `list_changed` notifications into a single
9221
9432
  // refresh (or one indicator light) once it settles.
@@ -9234,7 +9445,8 @@ var ManagedListState = class extends TypedEventTarget {
9234
9445
  if (this.config.deferWhenPaginated && this.client?.getServerSettings()?.paginatedLists) {
9235
9446
  return;
9236
9447
  }
9237
- void this.refresh();
9448
+ void this.refresh().catch(() => {
9449
+ });
9238
9450
  };
9239
9451
  const onListChanged = () => {
9240
9452
  if (this.listChangedTimer !== null) clearTimeout(this.listChangedTimer);
@@ -9252,6 +9464,7 @@ var ManagedListState = class extends TypedEventTarget {
9252
9464
  this.items = [];
9253
9465
  this.dispatchChange();
9254
9466
  this.setListChanged(false);
9467
+ this.setError(null);
9255
9468
  }
9256
9469
  };
9257
9470
  this.client.addEventListener("connect", onConnect);
@@ -9293,7 +9506,8 @@ var ManagedListState = class extends TypedEventTarget {
9293
9506
  const settings = this.client?.getServerSettings();
9294
9507
  const skipAggregate = this.config.deferWhenPaginated && settings?.paginatedLists;
9295
9508
  if (!skipAggregate && settings?.autoRefreshOnListChanged) {
9296
- await this.refresh(void 0, "refresh");
9509
+ await this.refresh(void 0, "refresh").catch(() => {
9510
+ });
9297
9511
  } else if (this.config.supportsIndicator) {
9298
9512
  this.setListChanged(true);
9299
9513
  }
@@ -9335,6 +9549,18 @@ var ManagedListState = class extends TypedEventTarget {
9335
9549
  this.listChanged = value;
9336
9550
  this.emit("listChangedChange", value);
9337
9551
  }
9552
+ /** The last fetch's failure, or `null` when the last fetch succeeded. */
9553
+ getError() {
9554
+ return this.error;
9555
+ }
9556
+ // Compared by identity rather than message: two distinct failures with the
9557
+ // same text are still two events, and a re-render on a repeat failure is
9558
+ // cheap next to silently coalescing them.
9559
+ setError(value) {
9560
+ if (this.error === value) return;
9561
+ this.error = value;
9562
+ this.emit("errorChange", value);
9563
+ }
9338
9564
  setMetadata(metadata) {
9339
9565
  this._metadata = metadata;
9340
9566
  }
@@ -9343,9 +9569,29 @@ var ManagedListState = class extends TypedEventTarget {
9343
9569
  * this fetch: `undefined` (the connect-time load) uses the default `'use'`;
9344
9570
  * a user-initiated or auto refresh passes `'refresh'` to force a
9345
9571
  * cache-bypassing round trip and re-store the fresh aggregate.
9572
+ *
9573
+ * A failure is recorded as observable state (`getError`) AND re-thrown: the
9574
+ * state drives the panel's error rendering, while the rejection is what the
9575
+ * caller's auth-recovery wrapper keys off to detect a 401 and start a
9576
+ * re-authorization. Callers with nobody to await them (the connect-time load,
9577
+ * the `list_changed` auto-refresh) catch it explicitly (#1953).
9346
9578
  */
9347
9579
  async refresh(metadata, cacheMode) {
9348
- const next = await this.fetchItems(metadata, cacheMode);
9580
+ let next;
9581
+ try {
9582
+ next = await this.fetchItems(metadata, cacheMode);
9583
+ } catch (err) {
9584
+ const error = err instanceof Error ? err : new Error(String(err));
9585
+ this.setError(error);
9586
+ if (isClientDecodeRejection(err)) {
9587
+ this.client?.markResponseRejected?.(
9588
+ this.config.listMethod,
9589
+ error.message
9590
+ );
9591
+ }
9592
+ throw err;
9593
+ }
9594
+ this.setError(null);
9349
9595
  if (next === null) return this.getItems();
9350
9596
  this.applyItems(next);
9351
9597
  return this.getItems();
@@ -9384,6 +9630,7 @@ var ManagedListState = class extends TypedEventTarget {
9384
9630
  var ManagedToolsState = class extends ManagedListState {
9385
9631
  constructor(client, debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS) {
9386
9632
  super(client, {
9633
+ listMethod: "tools/list",
9387
9634
  changeEvent: "toolsChange",
9388
9635
  listChangedEvent: "toolsListChanged",
9389
9636
  capabilityKey: "tools",
@@ -9445,6 +9692,20 @@ var MessageLogState = class extends TypedEventTarget {
9445
9692
  }
9446
9693
  pushEntry(entry);
9447
9694
  };
9695
+ const onResponseRejected = (event) => {
9696
+ const { id, reason } = event.detail;
9697
+ for (let i2 = this.messages.length - 1; i2 >= 0; i2--) {
9698
+ const entry = this.messages[i2];
9699
+ const entryId = entry.message.id;
9700
+ if (entryId !== id) continue;
9701
+ if (entry.direction === "notification") continue;
9702
+ if (entry.direction === "request" && !entry.response) continue;
9703
+ entry.clientError = reason;
9704
+ this.dispatchTypedEvent("message", entry);
9705
+ this.dispatchTypedEvent("messagesChange", this.getMessages());
9706
+ return;
9707
+ }
9708
+ };
9448
9709
  const onStatusChange = () => {
9449
9710
  if (isTerminalStatus(this.client?.getStatus())) {
9450
9711
  this.messages = [];
@@ -9453,10 +9714,12 @@ var MessageLogState = class extends TypedEventTarget {
9453
9714
  }
9454
9715
  };
9455
9716
  this.client.addEventListener("message", onMessage);
9717
+ this.client.addEventListener("responseRejected", onResponseRejected);
9456
9718
  this.client.addEventListener("statusChange", onStatusChange);
9457
9719
  this.unsubscribe = () => {
9458
9720
  if (this.client) {
9459
9721
  this.client.removeEventListener("message", onMessage);
9722
+ this.client.removeEventListener("responseRejected", onResponseRejected);
9460
9723
  this.client.removeEventListener("statusChange", onStatusChange);
9461
9724
  }
9462
9725
  this.client = null;
@@ -9502,6 +9765,7 @@ var MessageLogState = class extends TypedEventTarget {
9502
9765
  var ManagedResourcesState = class extends ManagedListState {
9503
9766
  constructor(client, debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS) {
9504
9767
  super(client, {
9768
+ listMethod: "resources/list",
9505
9769
  changeEvent: "resourcesChange",
9506
9770
  listChangedEvent: "resourcesListChanged",
9507
9771
  capabilityKey: "resources",
@@ -9523,6 +9787,7 @@ var ManagedResourcesState = class extends ManagedListState {
9523
9787
  var ManagedResourceTemplatesState = class extends ManagedListState {
9524
9788
  constructor(client, debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS) {
9525
9789
  super(client, {
9790
+ listMethod: "resources/templates/list",
9526
9791
  changeEvent: "resourceTemplatesChange",
9527
9792
  listChangedEvent: "resourceTemplatesListChanged",
9528
9793
  // Templates are gated on the broader `resources` capability.
@@ -9550,6 +9815,7 @@ var ManagedResourceTemplatesState = class extends ManagedListState {
9550
9815
  var ManagedPromptsState = class extends ManagedListState {
9551
9816
  constructor(client, debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS) {
9552
9817
  super(client, {
9818
+ listMethod: "prompts/list",
9553
9819
  changeEvent: "promptsChange",
9554
9820
  listChangedEvent: "promptsListChanged",
9555
9821
  capabilityKey: "prompts",
@@ -11308,8 +11574,8 @@ var C = Q.object({ method: Q.literal("ui/resource-teardown"), params: Q.object({
11308
11574
  var S = Q.record(Q.string(), Q.unknown());
11309
11575
  var F = Q.object({ text: Q.object({}).optional().describe("Host supports text content blocks."), image: Q.object({}).optional().describe("Host supports image content blocks."), audio: Q.object({}).optional().describe("Host supports audio content blocks."), resource: Q.object({}).optional().describe("Host supports resource content blocks."), resourceLink: Q.object({}).optional().describe("Host supports resource link content blocks."), structuredContent: Q.object({}).optional().describe("Host supports structured content.") });
11310
11576
  var q = Q.object({ method: Q.literal("ui/notifications/request-teardown"), params: Q.object({}).optional() });
11311
- var t = Q.object({ experimental: Q.object({}).optional().describe("Experimental features (structure TBD)."), openLinks: Q.object({}).optional().describe("Host supports opening external URLs."), downloadFile: Q.object({}).optional().describe("Host supports file downloads via ui/download-file."), serverTools: Q.object({ listChanged: Q.boolean().optional().describe("Host supports tools/list_changed notifications.") }).optional().describe("Host can proxy tool calls to the MCP server."), serverResources: Q.object({ listChanged: Q.boolean().optional().describe("Host supports resources/list_changed notifications.") }).optional().describe("Host can proxy resource reads to the MCP server."), logging: Q.object({}).optional().describe("Host accepts log messages."), sandbox: Q.object({ permissions: z4.optional().describe("Permissions granted by the host (camera, microphone, geolocation)."), csp: O.optional().describe("CSP domains approved by the host.") }).optional().describe("Sandbox configuration applied by the host."), updateModelContext: F.optional().describe("Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns."), message: F.optional().describe("Host supports receiving content messages (ui/message) from the view."), sampling: Q.object({ tools: Q.object({}).optional().describe("Host supports tool use via `tools` and `toolChoice` parameters.") }).optional().describe("Host supports LLM sampling (sampling/createMessage) from the view.\nMirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through.") });
11312
- var a = Q.object({ experimental: Q.object({}).optional().describe("Experimental features (structure TBD)."), tools: Q.object({ listChanged: Q.boolean().optional().describe("App supports tools/list_changed notifications.") }).optional().describe("App exposes MCP-style tools that the host can call."), availableDisplayModes: Q.array(B).optional().describe("Display modes the app supports.") });
11577
+ var t = Q.object({ experimental: Q.record(Q.string(), Q.record(Q.string(), Q.any()).describe("Experimental features keyed by identifier.")).optional().describe("Experimental features keyed by identifier."), openLinks: Q.object({}).optional().describe("Host supports opening external URLs."), downloadFile: Q.object({}).optional().describe("Host supports file downloads via ui/download-file."), serverTools: Q.object({ listChanged: Q.boolean().optional().describe("Host supports tools/list_changed notifications.") }).optional().describe("Host can proxy tool calls to the MCP server."), serverResources: Q.object({ listChanged: Q.boolean().optional().describe("Host supports resources/list_changed notifications.") }).optional().describe("Host can proxy resource reads to the MCP server."), logging: Q.object({}).optional().describe("Host accepts log messages."), sandbox: Q.object({ permissions: z4.optional().describe("Permissions granted by the host (camera, microphone, geolocation)."), csp: O.optional().describe("CSP domains approved by the host.") }).optional().describe("Sandbox configuration applied by the host."), updateModelContext: F.optional().describe("Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns."), message: F.optional().describe("Host supports receiving content messages (ui/message) from the view."), sampling: Q.object({ tools: Q.object({}).optional().describe("Host supports tool use via `tools` and `toolChoice` parameters.") }).optional().describe("Host supports LLM sampling (sampling/createMessage) from the view.\nMirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through.") });
11578
+ var a = Q.object({ experimental: Q.record(Q.string(), Q.record(Q.string(), Q.any()).describe("Experimental features keyed by identifier.")).optional().describe("Experimental features keyed by identifier."), tools: Q.object({ listChanged: Q.boolean().optional().describe("App supports tools/list_changed notifications.") }).optional().describe("App exposes MCP-style tools that the host can call."), availableDisplayModes: Q.array(B).optional().describe("Display modes the app supports.") });
11313
11579
  var y = Q.object({ method: Q.literal("ui/notifications/initialized"), params: Q.object({}).optional() });
11314
11580
  var TQ = Q.object({ csp: O.optional().describe("Content Security Policy configuration for UI resources."), permissions: z4.optional().describe("Sandbox permissions requested by the UI resource."), domain: Q.string().optional().describe(`Dedicated origin for view sandbox.
11315
11581