Fork resource subscriptions by protocol era: legacy keeps resources/subscribe; modern (2026-07-28) models subscriptions as a filter over one long-lived subscriptions/listen stream with reconnect-by-re-listen (capped backoff), an optimistic Connecting→Listening header badge, and a fix so the browser proxy no longer blocks listen() ~60s. Closes #1630.
This commit is contained in:
parent
a71e6d2012
commit
320daf2f67
9 changed files with 1656 additions and 90 deletions
|
|
@ -33,8 +33,10 @@ import type {
|
|||
PromptGetInvocation,
|
||||
ResourceReadInvocation,
|
||||
ResourceTemplateReadInvocation,
|
||||
ResourceSubscriptionStreamState,
|
||||
ToolCallInvocation,
|
||||
} from "../types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import type { JsonValue } from "../../json/jsonUtils.js";
|
||||
|
||||
type ListResult<TKey extends string, TItem> = {
|
||||
|
|
@ -249,6 +251,13 @@ export class FakeInspectorClient
|
|||
return this.discoverResult;
|
||||
}
|
||||
|
||||
resourceSubscriptionStreamState: ResourceSubscriptionStreamState =
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE;
|
||||
|
||||
getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState {
|
||||
return this.resourceSubscriptionStreamState;
|
||||
}
|
||||
|
||||
getServerSettings(): InspectorServerSettings | undefined {
|
||||
return this.serverSettings;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
AppRendererClient,
|
||||
InspectorClientOptions,
|
||||
PendingRequestOrigin,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "./types.js";
|
||||
// Re-export so v1.5 tests that do `import { InspectorClientOptions } from
|
||||
// "@inspector/core/mcp/inspectorClient.js"` keep resolving.
|
||||
|
|
@ -27,7 +28,11 @@ export type {
|
|||
AppRendererClient,
|
||||
} from "./types.js";
|
||||
import { getServerType as getServerTypeFromConfig } from "./config.js";
|
||||
import { DEFAULT_MODERN_LOG_LEVEL } from "./types.js";
|
||||
import {
|
||||
DEFAULT_MODERN_LOG_LEVEL,
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
isTerminalStatus,
|
||||
} from "./types.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
|
||||
|
|
@ -94,6 +99,8 @@ import type {
|
|||
InputRequests,
|
||||
InputRequiredOptions,
|
||||
StandardSchemaV1,
|
||||
McpSubscription,
|
||||
SubscriptionFilter,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
|
|
@ -247,6 +254,16 @@ interface ToolCallRequest {
|
|||
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
|
||||
|
|
@ -326,8 +343,31 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
resources: boolean;
|
||||
prompts: boolean;
|
||||
};
|
||||
// Resource subscriptions
|
||||
// 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"
|
||||
|
|
@ -1524,8 +1564,21 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
elicitation.cancel();
|
||||
}
|
||||
this.pendingElicitations = [];
|
||||
// Clear resource subscriptions on disconnect
|
||||
// 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.subscribedResources.clear();
|
||||
this.modernListenGeneration++;
|
||||
this.clearModernReconnectTimer();
|
||||
this.modernReconnectAttempts = 0;
|
||||
const closingSubscription = this.modernSubscription;
|
||||
this.modernSubscription = null;
|
||||
closingSubscription?.close().catch(() => {});
|
||||
this.modernStreamState = INACTIVE_SUBSCRIPTION_STREAM_STATE;
|
||||
this.dispatchTypedEvent(
|
||||
"resourceSubscriptionStreamChange",
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
);
|
||||
this.cancelledTaskIds.clear();
|
||||
// Abort any in-flight ordinary tool call so its promise settles instead of
|
||||
// hanging past teardown; drop the controller reference either way.
|
||||
|
|
@ -3527,7 +3580,240 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a resource to receive update notifications
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* (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.
|
||||
*/
|
||||
/** Cancel a pending reconnect re-listen, if any (#1630). */
|
||||
private clearModernReconnectTimer(): void {
|
||||
if (this.modernReconnectTimer !== undefined) {
|
||||
clearTimeout(this.modernReconnectTimer);
|
||||
this.modernReconnectTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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 previous.close().catch(() => {});
|
||||
}
|
||||
|
||||
// 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 subscription.close().catch(() => {});
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. (On a terminal-status drop
|
||||
// the disconnect reset clears the set and forces the inactive state.)
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -3539,12 +3825,38 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
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,
|
||||
});
|
||||
try {
|
||||
await this.refreshModernSubscription();
|
||||
} catch (error) {
|
||||
// Roll back the optimistic add + stream state so both stay consistent
|
||||
// with the (unchanged) server filter.
|
||||
this.subscribedResources.delete(uri);
|
||||
this.dispatchSubscriptionsChange();
|
||||
if (this.subscribedResources.size === 0) {
|
||||
this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this.client.subscribeResource({ uri }, this.getRequestOptions());
|
||||
this.subscribedResources.add(uri);
|
||||
this.dispatchTypedEvent(
|
||||
"resourceSubscriptionsChange",
|
||||
Array.from(this.subscribedResources),
|
||||
);
|
||||
this.dispatchSubscriptionsChange();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to subscribe to resource: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -3553,7 +3865,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a resource
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -3562,12 +3879,21 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
throw new Error("Client is not connected");
|
||||
}
|
||||
try {
|
||||
await this.client.unsubscribeResource({ uri }, this.getRequestOptions());
|
||||
this.subscribedResources.delete(uri);
|
||||
this.dispatchTypedEvent(
|
||||
"resourceSubscriptionsChange",
|
||||
Array.from(this.subscribedResources),
|
||||
);
|
||||
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).
|
||||
this.dispatchSubscriptionsChange();
|
||||
await this.refreshModernSubscription();
|
||||
} 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)}`,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type {
|
|||
PromptGetInvocation,
|
||||
ResourceReadInvocation,
|
||||
ResourceTemplateReadInvocation,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
Tool,
|
||||
|
|
@ -114,6 +115,13 @@ export interface InspectorClientEventMap {
|
|||
newPendingElicitation: ElicitationCreateMessage;
|
||||
rootsChange: Root[];
|
||||
resourceSubscriptionsChange: string[];
|
||||
/**
|
||||
* Fired when the modern-era `subscriptions/listen` stream that backs resource
|
||||
* subscriptions changes lifecycle state (#1630): opened+acknowledged, dropped
|
||||
* and reconnecting, or ended. Legacy connections never dispatch an `active`
|
||||
* state (they have no persistent stream).
|
||||
*/
|
||||
resourceSubscriptionStreamChange: ResourceSubscriptionStreamState;
|
||||
// Task events
|
||||
/** Fired only from server notification notifications/tasks/status. */
|
||||
taskStatusChange: { taskId: string; task: Task };
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
ResourceTemplateReadInvocation,
|
||||
PromptGetInvocation,
|
||||
ToolCallInvocation,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
CacheMode,
|
||||
|
|
@ -60,6 +61,7 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
getInstructions(): string | undefined;
|
||||
getProtocolVersion(): string | undefined;
|
||||
getProtocolEra(): ProtocolEra | undefined;
|
||||
getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState;
|
||||
getDiscoverResult(): DiscoverResult | undefined;
|
||||
getServerSettings(): InspectorServerSettings | undefined;
|
||||
setServerSettings(settings: InspectorServerSettings): void;
|
||||
|
|
|
|||
|
|
@ -335,7 +335,8 @@ function forwardLogEvent(
|
|||
}
|
||||
}
|
||||
|
||||
function requestIdForSendWait(
|
||||
// Exported for unit coverage of the `subscriptions/listen` exemption (#1630).
|
||||
export function requestIdForSendWait(
|
||||
message: JSONRPCMessage,
|
||||
): string | number | undefined {
|
||||
if (
|
||||
|
|
@ -344,6 +345,17 @@ function requestIdForSendWait(
|
|||
message.id !== null &&
|
||||
message.id !== undefined
|
||||
) {
|
||||
// `subscriptions/listen` (modern era, #1630) is a long-lived stream request:
|
||||
// it never produces a JSON-RPC response — it's answered by a
|
||||
// `notifications/subscriptions/acknowledged` and, only on graceful close, an
|
||||
// empty result. Waiting for a response would block `/api/mcp/send` for the
|
||||
// full timeout, delaying the client's `listen()` (which awaits `send()`) and
|
||||
// then tearing the stream down (`closed`) when the wait finally rejects,
|
||||
// which spuriously drives the reconnect loop. Don't wait: the ack and all
|
||||
// stream notifications reach the client over the SSE event channel.
|
||||
if (message.method === "subscriptions/listen") {
|
||||
return undefined;
|
||||
}
|
||||
return message.id;
|
||||
}
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,15 @@ function requestIdForMessage(
|
|||
message.id !== null &&
|
||||
message.id !== undefined
|
||||
) {
|
||||
// `subscriptions/listen` (modern era, #1630) is a long-lived stream request
|
||||
// with no JSON-RPC response — it's answered by a
|
||||
// `notifications/subscriptions/acknowledged` (delivered over the SSE event
|
||||
// channel) and, only on graceful close, an empty result. Waiting on a
|
||||
// response here would time out `send()` and, via the SDK's `listen()`,
|
||||
// spuriously drive the stream's `closed`/reconnect. Don't wait.
|
||||
if (message.method === "subscriptions/listen") {
|
||||
return undefined;
|
||||
}
|
||||
return message.id;
|
||||
}
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -21,8 +21,11 @@ import type {
|
|||
ManagedResourcesState,
|
||||
ManagedResourcesStateEventMap,
|
||||
} from "./managedResourcesState.js";
|
||||
import type { InspectorResourceSubscription } from "../types.js";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type {
|
||||
InspectorResourceSubscription,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "../types.js";
|
||||
import { isTerminalStatus, INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
TypedEventTarget,
|
||||
|
|
@ -31,6 +34,12 @@ import {
|
|||
|
||||
export interface ResourceSubscriptionsStateEventMap {
|
||||
subscriptionsChange: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Modern-era (2026-07-28) `subscriptions/listen` stream lifecycle (#1630).
|
||||
* Mirrors the client's `resourceSubscriptionStreamChange`; `active: false` on
|
||||
* the legacy era.
|
||||
*/
|
||||
streamStateChange: ResourceSubscriptionStreamState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,6 +51,8 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
private subscribedUris: string[] = [];
|
||||
private lastUpdatedByUri: Map<string, Date> = new Map();
|
||||
private subscriptions: InspectorResourceSubscription[] = [];
|
||||
private streamState: ResourceSubscriptionStreamState =
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private resourcesState: ManagedResourcesState | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
|
@ -53,6 +64,17 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
super();
|
||||
this.client = client;
|
||||
this.resourcesState = resourcesState;
|
||||
this.streamState = client.getResourceSubscriptionStreamState();
|
||||
|
||||
const onStreamStateChange = (
|
||||
event: TypedEventGeneric<
|
||||
InspectorClientEventMap,
|
||||
"resourceSubscriptionStreamChange"
|
||||
>,
|
||||
): void => {
|
||||
this.streamState = event.detail;
|
||||
this.dispatchTypedEvent("streamStateChange", this.streamState);
|
||||
};
|
||||
|
||||
const onSubscriptionsChange = (
|
||||
event: TypedEventGeneric<
|
||||
|
|
@ -87,7 +109,9 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
this.subscribedUris = [];
|
||||
this.lastUpdatedByUri.clear();
|
||||
this.subscriptions = [];
|
||||
this.streamState = INACTIVE_SUBSCRIPTION_STREAM_STATE;
|
||||
this.dispatchTypedEvent("subscriptionsChange", this.getSubscriptions());
|
||||
this.dispatchTypedEvent("streamStateChange", this.streamState);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -105,6 +129,10 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
"resourceSubscriptionsChange",
|
||||
onSubscriptionsChange,
|
||||
);
|
||||
client.addEventListener(
|
||||
"resourceSubscriptionStreamChange",
|
||||
onStreamStateChange,
|
||||
);
|
||||
client.addEventListener("resourceUpdated", onResourceUpdated);
|
||||
client.addEventListener("statusChange", onStatusChange);
|
||||
resourcesState?.addEventListener("resourcesChange", onResourcesChange);
|
||||
|
|
@ -114,6 +142,10 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
"resourceSubscriptionsChange",
|
||||
onSubscriptionsChange,
|
||||
);
|
||||
this.client?.removeEventListener(
|
||||
"resourceSubscriptionStreamChange",
|
||||
onStreamStateChange,
|
||||
);
|
||||
this.client?.removeEventListener("resourceUpdated", onResourceUpdated);
|
||||
this.client?.removeEventListener("statusChange", onStatusChange);
|
||||
this.resourcesState?.removeEventListener(
|
||||
|
|
@ -129,6 +161,14 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
return [...this.subscriptions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Current modern-era listen-stream state (#1630). `active: false` on the
|
||||
* legacy era, so the Resources screen can show/hide the stream chrome.
|
||||
*/
|
||||
getStreamState(): ResourceSubscriptionStreamState {
|
||||
return this.streamState;
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
const resources = this.resourcesState?.getResources() ?? [];
|
||||
const byUri = new Map(resources.map((r) => [r.uri, r]));
|
||||
|
|
@ -146,5 +186,6 @@ export class ResourceSubscriptionsState extends TypedEventTarget<ResourceSubscri
|
|||
this.subscribedUris = [];
|
||||
this.lastUpdatedByUri.clear();
|
||||
this.subscriptions = [];
|
||||
this.streamState = INACTIVE_SUBSCRIPTION_STREAM_STATE;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,6 +384,54 @@ export interface InspectorResourceSubscription {
|
|||
lastUpdated?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of the single modern-era `subscriptions/listen` stream that
|
||||
* backs every resource subscription on a 2026-07-28 server (#1630).
|
||||
*
|
||||
* - `"connecting"` — a `listen()` request is in flight and hasn't been
|
||||
* acknowledged yet (the optimistic state shown the moment the user subscribes,
|
||||
* so the UI responds to the click without waiting for the ack round-trip).
|
||||
* - `"acknowledged"` — the `listen()` request resolved and the server sent
|
||||
* `notifications/subscriptions/acknowledged`; the stream is open and carrying
|
||||
* updates.
|
||||
* - `"reconnecting"` — the stream dropped unexpectedly (`closed` resolved
|
||||
* `"remote"`) and a re-listen is in flight (reconnect-by-re-listen; there is
|
||||
* no resumability, so the re-listen re-establishes the full filter).
|
||||
* - `"ended"` — the server tore the stream down deliberately (`closed` resolved
|
||||
* `"graceful"`, e.g. on shutdown) or reconnection was abandoned; no automatic
|
||||
* re-listen.
|
||||
*/
|
||||
export type ResourceSubscriptionStreamStatus =
|
||||
| "connecting"
|
||||
| "acknowledged"
|
||||
| "reconnecting"
|
||||
| "ended";
|
||||
|
||||
/**
|
||||
* State of the modern-era resource-subscription listen stream (#1630).
|
||||
*
|
||||
* On the legacy era each `resources/subscribe` is an independent request with no
|
||||
* persistent stream, so `active` is `false` and the UI surfaces no stream chrome.
|
||||
* On the modern era all subscriptions are a filter over one long-lived
|
||||
* `subscriptions/listen` stream; `active` is `true` whenever that stream is being
|
||||
* managed (i.e. at least one URI is subscribed), and `honoredUris` is the subset
|
||||
* of requested URIs the server acknowledged in its `honoredFilter` (may be a
|
||||
* strict subset — a server is allowed to decline some).
|
||||
*/
|
||||
export interface ResourceSubscriptionStreamState {
|
||||
active: boolean;
|
||||
status: ResourceSubscriptionStreamStatus;
|
||||
honoredUris: string[];
|
||||
}
|
||||
|
||||
/** The stream state reported on the legacy era (or before any subscription). */
|
||||
export const INACTIVE_SUBSCRIPTION_STREAM_STATE: ResourceSubscriptionStreamState =
|
||||
{
|
||||
active: false,
|
||||
status: "ended",
|
||||
honoredUris: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a URL-based elicit request from the server. v1.5 only supports
|
||||
* form elicitation; v2 introduces URL elicitation as a discriminated variant
|
||||
|
|
|
|||
|
|
@ -3,17 +3,28 @@ import type {
|
|||
ResourceSubscriptionsState,
|
||||
ResourceSubscriptionsStateEventMap,
|
||||
} from "../mcp/state/resourceSubscriptionsState.js";
|
||||
import type { InspectorResourceSubscription } from "../mcp/types.js";
|
||||
import type {
|
||||
InspectorResourceSubscription,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "../mcp/types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../mcp/types.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
|
||||
export interface UseResourceSubscriptionsResult {
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Modern-era (2026-07-28) `subscriptions/listen` stream state (#1630).
|
||||
* `active: false` on the legacy era (and with no active server), so the
|
||||
* Resources screen renders no stream chrome there.
|
||||
*/
|
||||
streamState: ResourceSubscriptionStreamState;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that subscribes to ResourceSubscriptionsState and returns the
|
||||
* current InspectorResourceSubscription[]. When the state is null (no active
|
||||
* server), returns an empty array.
|
||||
* current InspectorResourceSubscription[] plus the modern listen-stream state.
|
||||
* When the state is null (no active server), returns an empty array and an
|
||||
* inactive stream state.
|
||||
*/
|
||||
export function useResourceSubscriptions(
|
||||
state: ResourceSubscriptionsState | null,
|
||||
|
|
@ -21,13 +32,19 @@ export function useResourceSubscriptions(
|
|||
const [subscriptions, setSubscriptions] = useState<
|
||||
InspectorResourceSubscription[]
|
||||
>(state?.getSubscriptions() ?? []);
|
||||
const [streamState, setStreamState] =
|
||||
useState<ResourceSubscriptionStreamState>(
|
||||
state?.getStreamState() ?? INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
setSubscriptions([]);
|
||||
setStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
|
||||
return;
|
||||
}
|
||||
setSubscriptions(state.getSubscriptions());
|
||||
setStreamState(state.getStreamState());
|
||||
const onSubscriptionsChange = (
|
||||
event: TypedEventGeneric<
|
||||
ResourceSubscriptionsStateEventMap,
|
||||
|
|
@ -36,11 +53,21 @@ export function useResourceSubscriptions(
|
|||
) => {
|
||||
setSubscriptions(event.detail);
|
||||
};
|
||||
const onStreamStateChange = (
|
||||
event: TypedEventGeneric<
|
||||
ResourceSubscriptionsStateEventMap,
|
||||
"streamStateChange"
|
||||
>,
|
||||
) => {
|
||||
setStreamState(event.detail);
|
||||
};
|
||||
state.addEventListener("subscriptionsChange", onSubscriptionsChange);
|
||||
state.addEventListener("streamStateChange", onStreamStateChange);
|
||||
return () => {
|
||||
state.removeEventListener("subscriptionsChange", onSubscriptionsChange);
|
||||
state.removeEventListener("streamStateChange", onStreamStateChange);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return { subscriptions };
|
||||
return { subscriptions, streamState };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue