Resources: subscriptions/listen model on modern servers (era fork) (#1630) (#1730)

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:
Cliff Hall 2026-07-20 19:33:40 -04:00 • committed by GitHub
commit 320daf2f67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1656 additions and 90 deletions

View file

@ -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;
}

View file

@ -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)}`,

View file

@ -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 };

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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

View file

@ -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 };
}