diff --git a/README.md b/README.md index a413e47ff..12c41b953 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** `test-servers/configs/logging-legacy-http.json` and `test-servers/configs/logging-modern-http.json` are the **logging era-fork showcase** (#1629). Both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level; the legacy one is a plain streamable-HTTP server (`logging/setLevel` era) and the modern one sets `transport.modern: true`. Connect to the legacy server and open the **Logs** tab to get the session-scoped **Set Active Level** selector + **Set** button; calling `send_notification` streams the log into the panel. Connect to the modern one with **Protocol Era = Modern** and the same tab instead shows the **Log Level per Request** control — pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body); calling `send_notification` then streams the log into the panel over the request's SSE response. Set the control back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`): on the modern leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream. +`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's *receive* path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.) + ## Building ```bash diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 60b1e814d..e9aaa44e7 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1030,9 +1030,8 @@ function App() { refresh: refreshTasks, clearCompleted: clearCompletedTasks, } = useManagedRequestorTasks(inspectorClient, managedRequestorTasksState); - const { subscriptions } = useResourceSubscriptions( - resourceSubscriptionsState, - ); + const { subscriptions, streamState: subscriptionStreamState } = + useResourceSubscriptions(resourceSubscriptionsState); const { messages } = useMessageLog(messageLogState); const { fetchRequests } = useFetchRequestLog(fetchRequestLogState); const { stderrLogs } = useStderrLog(stderrLogState); @@ -4182,6 +4181,7 @@ function App() { promptsListChanged={promptsListChanged} resourcesListChanged={resourcesListChanged} subscriptions={subscriptions} + subscriptionStreamState={subscriptionStreamState} logs={logs} tasks={tasks} progressByTaskId={progressByTaskId} diff --git a/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.stories.tsx b/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.stories.tsx new file mode 100644 index 000000000..314870a11 --- /dev/null +++ b/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge"; + +const meta: Meta = { + title: "Elements/SubscriptionStreamBadge", + component: SubscriptionStreamBadge, +}; + +export default meta; +type Story = StoryObj; + +export const Connecting: Story = { + args: { status: "connecting" }, +}; + +export const Acknowledged: Story = { + args: { status: "acknowledged" }, +}; + +export const Reconnecting: Story = { + args: { status: "reconnecting" }, +}; + +export const Ended: Story = { + args: { status: "ended" }, +}; diff --git a/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.test.tsx b/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.test.tsx new file mode 100644 index 000000000..873549fd1 --- /dev/null +++ b/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge"; +import { subscriptionStreamPresentation } from "./subscriptionStreamUtils"; + +describe("subscriptionStreamPresentation", () => { + it("maps each status to a color and label", () => { + expect(subscriptionStreamPresentation("connecting")).toMatchObject({ + color: "blue", + label: "Connecting…", + }); + expect(subscriptionStreamPresentation("acknowledged")).toMatchObject({ + color: "green", + label: "Listening", + }); + expect(subscriptionStreamPresentation("reconnecting")).toMatchObject({ + color: "yellow", + label: "Reconnecting…", + }); + expect(subscriptionStreamPresentation("ended")).toMatchObject({ + color: "gray", + label: "Stream ended", + }); + }); + + it("explains the listen stream in every tooltip", () => { + for (const status of [ + "connecting", + "acknowledged", + "reconnecting", + "ended", + ] as const) { + expect(subscriptionStreamPresentation(status).tooltip).toContain( + "subscriptions/listen stream", + ); + } + }); +}); + +describe("SubscriptionStreamBadge", () => { + it("renders a labelled badge by default", () => { + renderWithMantine(); + expect(screen.getByText("Listening")).toBeInTheDocument(); + }); + + it("renders a labelled reconnecting badge", () => { + renderWithMantine(); + expect(screen.getByText("Reconnecting…")).toBeInTheDocument(); + }); + + it("renders a labelled ended badge", () => { + renderWithMantine(); + expect(screen.getByText("Stream ended")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx b/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx new file mode 100644 index 000000000..5c0eb2b42 --- /dev/null +++ b/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx @@ -0,0 +1,27 @@ +import { Badge, Tooltip } from "@mantine/core"; +import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js"; +import { subscriptionStreamPresentation } from "./subscriptionStreamUtils"; + +export interface SubscriptionStreamBadgeProps { + /** Lifecycle status of the modern `subscriptions/listen` stream. */ + status: ResourceSubscriptionStreamStatus; +} + +/** + * Status indicator for the modern-era resource-subscription listen stream + * (#1630). Only meaningful on the modern era — the caller gates rendering on + * `streamState.active`. Renders a labelled dot badge (green/yellow/gray) in the + * Subscriptions section header, explaining the stream in a tooltip. + */ +export function SubscriptionStreamBadge({ + status, +}: SubscriptionStreamBadgeProps) { + const { color, label, tooltip } = subscriptionStreamPresentation(status); + return ( + + + {label} + + + ); +} diff --git a/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts b/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts new file mode 100644 index 000000000..c2c525368 --- /dev/null +++ b/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts @@ -0,0 +1,53 @@ +import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js"; + +export interface StreamPresentation { + /** Mantine palette color name conveying the status. */ + color: string; + /** Short label shown on the panel badge. */ + label: string; + /** Full explanation shown in the tooltip (both variants). */ + tooltip: string; +} + +const STREAM_INTRO = + "On modern (2026-07-28) servers, resource subscriptions are a filter over one long-lived subscriptions/listen stream."; + +// Keyed by status so it's exhaustive at compile time: a new +// `ResourceSubscriptionStreamStatus` that isn't handled here is a type error +// (no unreachable `default` needed, so it stays fully covered). +const PRESENTATION: Record< + ResourceSubscriptionStreamStatus, + StreamPresentation +> = { + connecting: { + color: "blue", + label: "Connecting…", + tooltip: `${STREAM_INTRO} Opening the stream — waiting for the server to acknowledge the subscription.`, + }, + acknowledged: { + color: "green", + label: "Listening", + tooltip: `${STREAM_INTRO} The server acknowledged the subscription and the stream is open, carrying resources/updated notifications.`, + }, + reconnecting: { + color: "yellow", + label: "Reconnecting…", + tooltip: `${STREAM_INTRO} The stream dropped unexpectedly; re-listening to re-establish it (there is no resumability, so the full filter is re-sent).`, + }, + ended: { + color: "gray", + label: "Stream ended", + tooltip: `${STREAM_INTRO} The stream is closed and won't reconnect on its own — either the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again.`, + }, +}; + +/** + * Maps a modern listen-stream status to its badge color, label, and tooltip + * copy (#1630). Kept in its own module so the badge component file exports only + * a component (react-refresh rule). + */ +export function subscriptionStreamPresentation( + status: ResourceSubscriptionStreamStatus, +): StreamPresentation { + return PRESENTATION[status]; +} diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx index 1366bcbf1..13f7bf94f 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx @@ -176,7 +176,7 @@ const subscriptionNotificationEntry: MessageEntry = { jsonrpc: "2.0", method: "notifications/resources/list_changed", params: { - _meta: { "io.modelcontextprotocol/subscriptionId": "sub-abc123" }, + _meta: { "io.modelcontextprotocol/subscriptionId": "listen:0" }, }, }, }; @@ -231,7 +231,8 @@ export const ModernInputRequired: Story = { }, }; -// Modern-era: a `server/discover` probe, flagged with the "modern" frame badge. +// Modern-era: a `server/discover` probe (a modern-only method). The connection +// era is shown once in the panel header, not per entry. export const ModernDiscoverFrame: Story = { args: { entry: discoverEntry, @@ -249,6 +250,25 @@ export const ModernSubscriptionNotification: Story = { }, }; +// Embedded (monitoring-sidebar) layout of a subscription notification: the +// `sub … listen-id` tag rides the top line (beside the direction) so the method +// badge on the line below gets the full narrow-column width (#1630). +export const EmbeddedSubscriptionNotification: Story = { + args: { + entry: subscriptionNotificationEntry, + isPinned: false, + isListExpanded: false, + embedded: true, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + // Embedded (monitoring-sidebar) layout with a long resource URI: the target sits // in a horizontal scroll area rather than truncating with an ellipsis. Wrapped // in the pinned column's width so the overflow is visible. diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index 19511e2ad..070e769bd 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -471,14 +471,10 @@ describe("ProtocolEntry — modern vocabulary", () => { expect(screen.queryByText("complete")).not.toBeInTheDocument(); }); - it("flags a modern-only frame with a 'modern' badge", () => { + it("renders a modern-only frame's method (no per-frame era badge)", () => { renderWithMantine(); expect(screen.getByText("server/discover")).toBeInTheDocument(); - expect(screen.getByText("modern")).toBeInTheDocument(); - }); - - it("does not flag an ordinary frame as modern", () => { - renderWithMantine(); + // The connection era is shown once in the panel header, not per entry. expect(screen.queryByText("modern")).not.toBeInTheDocument(); }); @@ -494,6 +490,17 @@ describe("ProtocolEntry — modern vocabulary", () => { ).toBeGreaterThanOrEqual(1); }); + it("shows the subscriptionId in the embedded compact layout", () => { + renderWithMantine( + , + ); + expect(screen.getByText("sub-abc")).toBeInTheDocument(); + }); + it("shows the modern badges in the embedded compact layout", () => { renderWithMantine( , diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index 2853ffdce..9cd6ee810 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -29,7 +29,6 @@ import { extractMethod, extractResultType, extractSubscriptionId, - isModernFrame, isReplayableProtocolMethod, } from "../protocolUtils.js"; @@ -109,15 +108,6 @@ const DurationText = Text.withProps({ c: "dimmed", }); -// Flags a frame whose method only exists in the modern (2026-07-28) era -// (`server/discover`, `subscriptions/listen`, its acknowledgement). This labels -// the *frame*, not the connection's negotiated era (see the transcript caveat in -// protocolUtils) — those methods genuinely cannot occur on a legacy connection. -const ModernFrameBadge = Badge.withProps({ - color: "blue", - variant: "outline", -}); - // The `subscriptionId` tag on a modern push notification (spec §7.4). Shown with // a copy button so the id can be correlated against the `subscriptions/listen` // stream that opened it. @@ -280,7 +270,6 @@ export function ProtocolEntry({ const canReplay = isReplayableProtocolMethod(method); const resultType = extractResultType(entry); const subscriptionId = extractSubscriptionId(entry); - const isModern = isModernFrame(method); useEffect(() => { setIsExpanded(isListExpanded); @@ -319,9 +308,6 @@ export function ProtocolEntry({ {statusLabel(status)} ); - const modernFrameBadge = isModern && ( - modern - ); const subscriptionBadge = subscriptionId && ( sub @@ -350,13 +336,16 @@ export function ProtocolEntry({ {durationText} {resultTypeBadge} {statusBadge} + {/* The subscription-id tag rides the top line's trailing edge + (a notification row's duration/status slots are empty) so the + method badge on the line below gets the full column width and + doesn't truncate against the pin control (#1630). */} + {subscriptionBadge} - {modernFrameBadge} - {subscriptionBadge} {target && ( <> {resourceUri && } @@ -395,7 +384,6 @@ export function ProtocolEntry({ {directionBadge} {specErrorBadge} - {modernFrameBadge} {subscriptionBadge} {target && ( <> diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx index df72686b7..c920fb320 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx @@ -333,4 +333,66 @@ describe("ResourceControls", () => { await user.type(screen.getByPlaceholderText("Search..."), "special"); expect(screen.getByText("URIs (1)")).toBeInTheDocument(); }); + + describe("modern listen-stream chrome (#1630)", () => { + const activeAck = { + active: true as const, + status: "acknowledged" as const, + honoredUris: ["file:///config.json"], + }; + + it("shows the stream badge in the section header on the modern era", () => { + renderWithMantine( + , + ); + // The labelled badge sits in the accordion header (next to the count). + expect(screen.getByText("Listening")).toBeInTheDocument(); + }); + + it("renders no stream chrome on the legacy era even when a stream is active", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("Listening")).not.toBeInTheDocument(); + }); + + it("renders no stream chrome on the modern era when the stream is inactive", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText(/Listening|Stream ended/), + ).not.toBeInTheDocument(); + }); + + it("hides the stream badge when a search filters out all subscriptions", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + expect(screen.getByText("Listening")).toBeInTheDocument(); + + // A query matching none of the (live) subscriptions empties the section; + // the badge hides with it rather than sitting next to "Subscriptions (0)". + await user.type( + screen.getByPlaceholderText("Search..."), + "no-such-resource", + ); + expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument(); + expect(screen.queryByText("Listening")).not.toBeInTheDocument(); + }); + }); }); diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index fea2e5181..3e63ca4c9 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -1,11 +1,17 @@ -import { Accordion, Group, Stack, TextInput, Title } from "@mantine/core"; +import { Accordion, Group, Stack, Text, TextInput, Title } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { RiArrowRightSLine } from "react-icons/ri"; import type { + ProtocolEra, Resource, ResourceTemplateType as ResourceTemplate, } from "@modelcontextprotocol/client"; -import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; +import type { + InspectorResourceSubscription, + ResourceSubscriptionStreamState, +} from "../../../../../../core/mcp/types.js"; +import { isModernEra } from "../../elements/EraBadge/eraUtils"; +import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListPaginationControls, @@ -26,6 +32,15 @@ export interface ResourceControlsProps { * explicitly marks subscriptions unsupported. */ subscriptionsSupported?: boolean; + /** + * Modern-era `subscriptions/listen` stream state (#1630). When `active` + * (modern era with at least one subscription) the Subscriptions section shows + * a stream-status badge in its panel and a status dot in its header. Legacy + * connections pass `active: false` (or omit it) and see neither. + */ + subscriptionStreamState?: ResourceSubscriptionStreamState; + /** Negotiated protocol era; gates the modern subscription stream chrome. */ + protocolEra?: ProtocolEra; selectedUri?: string; selectedTemplateUri?: string; // Search text + accordion open-sections are controlled by the parent (App, @@ -71,6 +86,8 @@ export function ResourceControls({ templates, subscriptions, subscriptionsSupported = true, + subscriptionStreamState, + protocolEra, selectedUri, selectedTemplateUri, searchText = "", @@ -106,6 +123,20 @@ export function ResourceControls({ s.resource.uri.toLowerCase().includes(query), ); + // Modern-era chrome for the single `subscriptions/listen` stream (#1630): + // a status badge in the section header (so it stays visible while the section + // is collapsed). Only shown on the modern era while the stream is active + // (≥1 subscription); the legacy per-URI `resources/subscribe` model has no + // persistent stream. Also gated on the *filtered* count so the badge hides + // alongside the section when a search matches none of the live subscriptions + // (rather than sitting next to a disabled "Subscriptions (0)" header). + const streamStatus = + isModernEra(protocolEra) && + subscriptionStreamState?.active === true && + filteredSubscriptions.length > 0 + ? subscriptionStreamState.status + : undefined; + // Subscriptions are only meaningful when the server advertises the // `resources.subscribe` capability; otherwise the section is omitted // entirely (no header, no panel) — see #1478. @@ -271,10 +302,17 @@ export function ResourceControls({ )} > - {formatSectionCount( - "Subscriptions", - filteredSubscriptions.length, - )} + + + {formatSectionCount( + "Subscriptions", + filteredSubscriptions.length, + )} + + {streamStatus && ( + + )} + diff --git a/clients/web/src/components/groups/protocolUtils.test.ts b/clients/web/src/components/groups/protocolUtils.test.ts index a61870904..fff5c5e65 100644 --- a/clients/web/src/components/groups/protocolUtils.test.ts +++ b/clients/web/src/components/groups/protocolUtils.test.ts @@ -7,7 +7,6 @@ import { extractResultType, extractSubscriptionId, groupProtocolEntries, - isModernFrame, } from "./protocolUtils"; // A tools/call request entry whose paired response is the given result. `at` @@ -86,22 +85,6 @@ describe("extractMethod", () => { }); }); -describe("isModernFrame", () => { - it.each([ - "server/discover", - "subscriptions/listen", - "notifications/subscriptions/acknowledged", - ])("recognizes the modern frame %s", (method) => { - expect(isModernFrame(method)).toBe(true); - }); - - it("returns false for legacy/ordinary methods", () => { - expect(isModernFrame("tools/call")).toBe(false); - expect(isModernFrame("notifications/initialized")).toBe(false); - expect(isModernFrame("response")).toBe(false); - }); -}); - describe("extractResultType", () => { it("returns 'input_required' when the result carries the discriminator", () => { const entry = callEntry( diff --git a/clients/web/src/components/groups/protocolUtils.ts b/clients/web/src/components/groups/protocolUtils.ts index 3b3f11fa0..845f39214 100644 --- a/clients/web/src/components/groups/protocolUtils.ts +++ b/clients/web/src/components/groups/protocolUtils.ts @@ -50,21 +50,6 @@ export function isReplayableProtocolMethod(method: string): boolean { // envelope/resultType" ≠ "modern negotiated" (spec §8.3). Era labeling comes // from the negotiated `protocolEra` connection state, threaded in as a prop. -/** - * Frame methods that only exist in the modern era: the discovery probe, the - * push-notification listen stream, and its acknowledgement. Generic viewers - * already display them; this set drives the small "modern frame" affordance. - */ -export const MODERN_METHODS: ReadonlySet = new Set([ - "server/discover", - "subscriptions/listen", - "notifications/subscriptions/acknowledged", -]); - -export function isModernFrame(method: string): boolean { - return MODERN_METHODS.has(method); -} - // The successful `result` object of a request entry's paired response, or // undefined when there is no response or it was an error. (messageLogState folds // a request's response onto the request entry by JSON-RPC id.) diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index aee4edbf5..b1de0328c 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -239,6 +239,27 @@ export const SubscriptionsUnsupported: Story = { }, }; +// Modern (2026-07-28) era: subscriptions ride one `subscriptions/listen` +// stream, so the Subscriptions section shows a stream-status badge in its panel +// and a status dot in its header (#1630). +export const ModernSubscriptionStream: Story = { + args: { + resources: sampleResources, + templates: sampleTemplates, + subscriptions: sampleSubscriptions, + protocolEra: "modern", + subscriptionStreamState: { + active: true, + status: "acknowledged", + honoredUris: sampleSubscriptions.map((s) => s.resource.uri), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("Listening")).toBeInTheDocument(); + }, +}; + const manyResources: Resource[] = Array.from({ length: 40 }, (_, i) => ({ name: `resource-${String(i + 1).padStart(2, "0")}.wav`, uri: `file:///kit/resource-${i + 1}.wav`, diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 95ddd2200..1a8040b4f 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -9,11 +9,15 @@ import { Text, } from "@mantine/core"; import type { + ProtocolEra, ReadResourceResult, Resource, ResourceTemplateType as ResourceTemplate, } from "@modelcontextprotocol/client"; -import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; +import type { + InspectorResourceSubscription, + ResourceSubscriptionStreamState, +} from "../../../../../../core/mcp/types.js"; import { ResourceControls } from "../../groups/ResourceControls/ResourceControls"; import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel"; @@ -32,6 +36,14 @@ export interface ResourcesScreenProps { resources: Resource[]; templates: ResourceTemplate[]; subscriptions: InspectorResourceSubscription[]; + /** + * Modern-era `subscriptions/listen` stream state (#1630). On the modern era + * the Subscriptions section shows a stream-status badge and a header dot; + * `active: false` (the legacy default) renders neither. + */ + subscriptionStreamState?: ResourceSubscriptionStreamState; + /** Negotiated protocol era; gates the modern subscription stream chrome. */ + protocolEra?: ProtocolEra; readState?: ReadResourceState; ui: ResourcesUiState; listChanged: boolean; @@ -137,6 +149,8 @@ export function ResourcesScreen({ resources, templates, subscriptions, + subscriptionStreamState, + protocolEra, readState, ui, listChanged, @@ -291,6 +305,8 @@ export function ResourcesScreen({ templates={templates} subscriptions={subscriptions} subscriptionsSupported={subscriptionsSupported} + subscriptionStreamState={subscriptionStreamState} + protocolEra={protocolEra} selectedUri={selectedResourceUri} selectedTemplateUri={selectedTemplateUri} searchText={search} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index a15260e77..8a4b3a77c 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -32,6 +32,7 @@ import type { FetchRequestEntry, InspectorResourceSubscription, MessageEntry, + ResourceSubscriptionStreamState, ServerEntry, StderrLogEntry, } from "@inspector/core/mcp/types.js"; @@ -394,6 +395,12 @@ export interface InspectorViewProps { resources: Resource[]; resourceTemplates: ResourceTemplate[]; subscriptions: InspectorResourceSubscription[]; + /** + * Modern-era `subscriptions/listen` stream state (#1630). Drives the + * Resources screen's stream badge/dot; `active: false` (or omitted) on the + * legacy era. + */ + subscriptionStreamState?: ResourceSubscriptionStreamState; // "List changed since last refresh" flags, sourced from the managed-state // layer (#1402). They light the per-screen list-changed indicator. Apps is a @@ -593,6 +600,7 @@ export function InspectorView({ promptsListChanged, resourcesListChanged, subscriptions, + subscriptionStreamState, logs, tasks, progressByTaskId, @@ -1352,6 +1360,8 @@ export function InspectorView({ resources={resources} templates={resourceTemplates} subscriptions={subscriptions} + subscriptionStreamState={subscriptionStreamState} + protocolEra={protocolEra} readState={readResourceState} ui={resourcesUi} listChanged={resourcesListChanged} diff --git a/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts b/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts index a328ceb0e..c9cc3a5c7 100644 --- a/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts +++ b/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts @@ -236,4 +236,71 @@ describe("ResourceSubscriptionsState", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); }); + + describe("modern listen-stream state (#1630)", () => { + it("starts inactive and seeds from the client", () => { + const state = new ResourceSubscriptionsState(client); + expect(state.getStreamState()).toEqual({ + active: false, + status: "ended", + honoredUris: [], + }); + + client.resourceSubscriptionStreamState = { + active: true, + status: "acknowledged", + honoredUris: ["file:///a"], + }; + const seeded = new ResourceSubscriptionsState(client); + expect(seeded.getStreamState().active).toBe(true); + expect(seeded.getStreamState().honoredUris).toEqual(["file:///a"]); + }); + + it("forwards resourceSubscriptionStreamChange as streamStateChange", async () => { + const state = new ResourceSubscriptionsState(client); + const next = await new Promise((resolve) => { + state.addEventListener("streamStateChange", (e) => resolve(e.detail), { + once: true, + }); + client.dispatchTypedEvent("resourceSubscriptionStreamChange", { + active: true, + status: "reconnecting", + honoredUris: [], + }); + }); + expect(next).toEqual({ + active: true, + status: "reconnecting", + honoredUris: [], + }); + expect(state.getStreamState().status).toBe("reconnecting"); + }); + + it("resets stream state to inactive on a terminal status change", () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionStreamChange", { + active: true, + status: "acknowledged", + honoredUris: ["file:///a"], + }); + expect(state.getStreamState().active).toBe(true); + + const handler = vi.fn(); + state.addEventListener("streamStateChange", handler); + client.setStatus("disconnected"); + expect(state.getStreamState().active).toBe(false); + expect(handler).toHaveBeenCalled(); + }); + + it("resets stream state on destroy", () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionStreamChange", { + active: true, + status: "acknowledged", + honoredUris: ["file:///a"], + }); + state.destroy(); + expect(state.getStreamState().active).toBe(false); + }); + }); }); diff --git a/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx b/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx index 164444fc5..e11ff5191 100644 --- a/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx +++ b/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx @@ -66,4 +66,57 @@ describe("useResourceSubscriptions", () => { client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); expect(result.current.subscriptions).toEqual([]); }); + + it("returns an inactive stream state by default and when null", () => { + const { result: nullResult } = renderHook(() => + useResourceSubscriptions(null), + ); + expect(nullResult.current.streamState.active).toBe(false); + + const { result } = renderHook(() => useResourceSubscriptions(state)); + expect(result.current.streamState.active).toBe(false); + }); + + it("updates streamState when the store dispatches streamStateChange", async () => { + const { result } = renderHook(() => useResourceSubscriptions(state)); + + act(() => { + client.dispatchTypedEvent("resourceSubscriptionStreamChange", { + active: true, + status: "acknowledged", + honoredUris: ["file:///a"], + }); + }); + + await waitFor(() => { + expect(result.current.streamState).toEqual({ + active: true, + status: "acknowledged", + honoredUris: ["file:///a"], + }); + }); + }); + + it("resets streamState when the state prop becomes null", async () => { + const { result, rerender } = renderHook( + ({ s }: { s: ResourceSubscriptionsState | null }) => + useResourceSubscriptions(s), + { initialProps: { s: state as ResourceSubscriptionsState | null } }, + ); + act(() => { + client.dispatchTypedEvent("resourceSubscriptionStreamChange", { + active: true, + status: "acknowledged", + honoredUris: [], + }); + }); + await waitFor(() => { + expect(result.current.streamState.active).toBe(true); + }); + + rerender({ s: null }); + await waitFor(() => { + expect(result.current.streamState.active).toBe(false); + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts new file mode 100644 index 000000000..a0ba41007 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import type { McpSubscription } from "@modelcontextprotocol/client"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createNumberedResources, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; +import type { MessageEntry } from "@inspector/core/mcp/types.js"; + +/** + * Live coverage of the resource-subscription era fork (#1630). On the legacy era + * each subscription is a `resources/subscribe` request; on the modern + * (2026-07-28) era subscriptions are a filter over one `subscriptions/listen` + * stream. Both are exercised against a real server over a real transport. + */ +describe("resource subscriptions era fork (#1630)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + const RESOURCE_URI = "test://resource_0"; + const RESOURCE_URI_2 = "test://resource_1"; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + async function startServer( + modern: ServerConfig["modern"] | undefined, + ): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("subscriptions-era-test", "1.0.0"), + resources: createNumberedResources(2), + listChanged: { resources: true }, + subscriptions: true, + ...(modern ? { modern } : {}), + }); + await started.start(); + server = started; + return started; + } + + async function connect( + url: string, + era: "legacy" | "modern", + ): Promise<{ connected: InspectorClient; messages: MessageEntry[] }> { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation(era), + }, + ); + const messages: MessageEntry[] = []; + connected.addEventListener("message", (event) => { + messages.push(event.detail); + }); + await connected.connect(); + client = connected; + return { connected, messages }; + } + + function methodsSent(messages: MessageEntry[]): string[] { + return messages + .filter((m) => m.direction === "request") + .map((m) => ("method" in m.message ? m.message.method : "")) + .filter(Boolean); + } + + /** Params of the last `subscriptions/listen` request captured in the log. */ + function lastListenFilter( + messages: MessageEntry[], + ): Record | undefined { + const listen = messages + .filter( + (m) => + m.direction === "request" && + "method" in m.message && + m.message.method === "subscriptions/listen", + ) + .at(-1); + if (!listen || !("params" in listen.message)) return undefined; + const params = listen.message.params as { notifications?: unknown }; + return params.notifications as Record | undefined; + } + + describe("modern era", () => { + it("opens an acknowledged listen stream on subscribe (no resources/subscribe)", async () => { + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + expect(connected.getProtocolEra()).toBe("modern"); + expect(connected.supportsResourceSubscriptions()).toBe(true); + + messages.length = 0; + await connected.subscribeToResource(RESOURCE_URI); + + const streamState = connected.getResourceSubscriptionStreamState(); + expect(streamState.active).toBe(true); + expect(streamState.status).toBe("acknowledged"); + expect(streamState.honoredUris).toContain(RESOURCE_URI); + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + + const methods = methodsSent(messages); + expect(methods).toContain("subscriptions/listen"); + expect(methods).not.toContain("resources/subscribe"); + }); + + it("closes the stream when the last subscription is removed", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + expect(connected.getResourceSubscriptionStreamState().active).toBe(true); + + await connected.unsubscribeFromResource(RESOURCE_URI); + const streamState = connected.getResourceSubscriptionStreamState(); + expect(streamState.active).toBe(false); + expect(connected.getSubscribedResources()).toEqual([]); + }); + + it("re-lists (stream stays open) when one of several URIs is removed", async () => { + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + await connected.subscribeToResource(RESOURCE_URI_2); + expect(connected.getSubscribedResources()).toEqual([ + RESOURCE_URI, + RESOURCE_URI_2, + ]); + + messages.length = 0; + await connected.unsubscribeFromResource(RESOURCE_URI); + + // A fresh listen re-established the reduced filter; the stream is still up. + const streamState = connected.getResourceSubscriptionStreamState(); + expect(streamState.active).toBe(true); + expect(streamState.status).toBe("acknowledged"); + expect(streamState.honoredUris).toEqual([RESOURCE_URI_2]); + expect(methodsSent(messages)).toContain("subscriptions/listen"); + }); + + it("folds the subscribed URIs and listChanged opt-ins into the listen filter", async () => { + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + messages.length = 0; + await connected.subscribeToResource(RESOURCE_URI); + + const filter = lastListenFilter(messages); + expect(filter?.resourceSubscriptions).toEqual([RESOURCE_URI]); + // The server advertises resources.listChanged, so the single stream also + // opts into it (one listen stream carries every opted-in type). + expect(filter?.resourcesListChanged).toBe(true); + }); + + it("skips a redundant re-list when re-subscribing an already-subscribed URI", async () => { + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + // A second subscribe of the same URI leaves the filter unchanged, so it + // must not re-list (which would needlessly churn the server stream). + messages.length = 0; + await connected.subscribeToResource(RESOURCE_URI); + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(true); + }); + + it("skips a re-list when unsubscribing a URI that isn't subscribed", async () => { + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + messages.length = 0; + await connected.unsubscribeFromResource("test://not-subscribed"); + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + // The real subscription is untouched. + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + }); + }); + + describe("legacy era", () => { + it("subscribes via resources/subscribe with no listen stream", async () => { + const started = await startServer(undefined); + const { connected, messages } = await connect(started.url, "legacy"); + expect(connected.getProtocolEra()).toBe("legacy"); + expect(connected.supportsResourceSubscriptions()).toBe(true); + + messages.length = 0; + await connected.subscribeToResource(RESOURCE_URI); + + const methods = methodsSent(messages); + expect(methods).toContain("resources/subscribe"); + expect(methods).not.toContain("subscriptions/listen"); + + // No persistent stream on the legacy era. + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + + messages.length = 0; + await connected.unsubscribeFromResource(RESOURCE_URI); + expect(methodsSent(messages)).toContain("resources/unsubscribe"); + expect(connected.getSubscribedResources()).toEqual([]); + }); + }); + + describe("guards", () => { + it("rejects subscribe when the server does not support subscriptions", async () => { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("no-subscribe-test", "1.0.0"), + resources: createNumberedResources(1), + // subscriptions omitted → capability not advertised + modern: {}, + }); + await started.start(); + server = started; + const { connected } = await connect(started.url, "modern"); + expect(connected.supportsResourceSubscriptions()).toBe(false); + await expect(connected.subscribeToResource(RESOURCE_URI)).rejects.toThrow( + /does not support resource subscriptions/, + ); + }); + }); + + // Modern stream lifecycle branches that the public surface can't reach against + // a healthy server (an unexpected drop, a failed `listen()`). These reach into + // the client's private state — the pattern used across the InspectorClient + // coverage-backfill suite — to drive them deterministically. + describe("modern stream internals", () => { + interface StreamInternals { + client: { listen: (...args: unknown[]) => Promise }; + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + modernReconnectAttempts: number; + subscribedResources: Set; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + } + + function internals(c: InspectorClient): StreamInternals { + return c as unknown as StreamInternals; + } + + /** A controllable fake `McpSubscription` whose `closed` we resolve on demand. */ + function makeFakeSub(): { + sub: McpSubscription; + drop: (reason: "local" | "graceful" | "remote") => void; + } { + let drop: (reason: "local" | "graceful" | "remote") => void = () => {}; + const closed = new Promise<"local" | "graceful" | "remote">((resolve) => { + drop = resolve; + }); + const sub = { + honoredFilter: { resourceSubscriptions: [RESOURCE_URI] }, + close: async () => {}, + closed, + } as McpSubscription; + return { sub, drop }; + } + + /** + * Replace the client's live listen stream with a controllable fake and close + * the real one, so tests that drive `onModernSubscriptionClosed` by hand + * don't leave a real stream open (which would reject "Connection closed" on + * teardown). Returns the installed fake. + */ + async function installFakeSubscription( + int: StreamInternals, + ): Promise> { + const real = int.modernSubscription; + const fake = makeFakeSub(); + int.modernSubscription = fake.sub; + // real.closed fires onModernSubscriptionClosed, but modernSubscription is + // now the fake, so it's a no-op guard-wise; this just tears down the wire. + await real?.close().catch(() => {}); + return fake; + } + + it("rolls back the optimistic add when listen() fails", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + const real = internals(connected).client.listen; + internals(connected).client.listen = () => + Promise.reject(new Error("listen boom")); + + await expect(connected.subscribeToResource(RESOURCE_URI)).rejects.toThrow( + /listen boom/, + ); + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + + internals(connected).client.listen = real; + }); + + it("reflects the subscription optimistically as 'connecting' before the ack", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + const int = internals(connected); + + // Hold the listen ack so we can observe the pre-ack (optimistic) state. + let ack: (sub: McpSubscription) => void = () => {}; + int.client.listen = () => + new Promise((resolve) => { + ack = resolve; + }); + + const pending = connected.subscribeToResource(RESOURCE_URI); + // The URI and a "connecting" stream state are visible immediately, without + // waiting for the round-trip. + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + const connecting = connected.getResourceSubscriptionStreamState(); + expect(connecting.active).toBe(true); + expect(connecting.status).toBe("connecting"); + + // The ack lands → acknowledged. + ack(makeFakeSub().sub); + await pending; + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + }); + + it("reconnects by re-listing after an unexpected 'remote' drop", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + const int = internals(connected); + const fake = await installFakeSubscription(int); + int.onModernSubscriptionClosed( + fake.sub, + "remote", + int.modernListenGeneration, + ); + + // Synchronously flips to reconnecting, then re-lists and re-acknowledges. + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + await vi.waitFor(() => { + const s = connected.getResourceSubscriptionStreamState(); + expect(s.active).toBe(true); + expect(s.status).toBe("acknowledged"); + }); + }); + + it("retries a failing re-listen with backoff and gives up past the cap", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + const int = internals(connected); + const fake = await installFakeSubscription(int); + + vi.useFakeTimers(); + try { + // Every re-listen fails, so the failure count climbs each retry. + int.client.listen = () => Promise.reject(new Error("re-listen boom")); + + int.onModernSubscriptionClosed( + fake.sub, + "remote", + int.modernListenGeneration, + ); + // A single failure retries (not ended yet). + await vi.advanceTimersByTimeAsync(20_000); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + + // Keep failing until the consecutive-failure cap gives up. + for (let i = 0; i < 12; i++) { + if (connected.getResourceSubscriptionStreamState().status === "ended") + break; + await vi.advanceTimersByTimeAsync(20_000); + } + const state = connected.getResourceSubscriptionStreamState(); + expect(state.status).toBe("ended"); + // Subscriptions remain, so the ended badge stays visible. + expect(state.active).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("resets the failure count after a successful reconnect", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + const int = internals(connected); + const fake = await installFakeSubscription(int); + + vi.useFakeTimers(); + try { + // The first two re-lists fail (count climbs), the third acknowledges. + let failures = 0; + int.client.listen = () => { + if (failures < 2) { + failures += 1; + return Promise.reject(new Error("re-listen boom")); + } + return Promise.resolve(makeFakeSub().sub); + }; + + int.onModernSubscriptionClosed( + fake.sub, + "remote", + int.modernListenGeneration, + ); + // Advance through the two failures and the successful ack. + for (let i = 0; i < 4; i++) { + await vi.advanceTimersByTimeAsync(20_000); + } + // A successful ack resets the run, so a subsequent drop starts fresh. + expect(int.modernReconnectAttempts).toBe(0); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("does not reconnect when the subscription set empties before the timer fires", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + const int = internals(connected); + const fake = await installFakeSubscription(int); + + vi.useFakeTimers(); + try { + int.client.listen = () => { + throw new Error("re-listen should not run once the set is empty"); + }; + int.onModernSubscriptionClosed( + fake.sub, + "remote", + int.modernListenGeneration, + ); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + // Empty the set without going through unsubscribe (which would clear the + // timer), then fire it: the guard bails instead of re-listing. + int.subscribedResources.clear(); + await vi.advanceTimersByTimeAsync(20_000); + expect(int.modernSubscription).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the ended badge active on a graceful close while subscriptions remain", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + const int = internals(connected); + const fake = await installFakeSubscription(int); + int.onModernSubscriptionClosed( + fake.sub, + "graceful", + int.modernListenGeneration, + ); + const state = connected.getResourceSubscriptionStreamState(); + expect(state.status).toBe("ended"); + // Subscriptions remain, so the ended badge stays visible (parity with the + // reconnect give-up state). + expect(state.active).toBe(true); + }); + + it("goes inactive on a graceful close once no subscriptions remain", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + const int = internals(connected); + const fake = await installFakeSubscription(int); + // No URIs left → the ended state is inactive (no badge). + int.subscribedResources.clear(); + int.onModernSubscriptionClosed( + fake.sub, + "graceful", + int.modernListenGeneration, + ); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("ignores a close callback from a superseded generation", async () => { + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + const int = internals(connected); + const fake = await installFakeSubscription(int); + const before = connected.getResourceSubscriptionStreamState(); + // A stale generation → the callback is a no-op (no reconnect, no change). + int.onModernSubscriptionClosed( + fake.sub, + "remote", + int.modernListenGeneration - 1, + ); + expect(connected.getResourceSubscriptionStreamState()).toEqual(before); + }); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 24ecc8143..05b8346d6 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -562,6 +562,30 @@ describe("RemoteClientTransport (focused branch coverage)", () => { ).rejects.toThrow(/Transport is closed/); }); + it("does not wait for an SSE response on subscriptions/listen (long-lived stream)", async () => { + // A `subscriptions/listen` never yields a JSON-RPC response — only an + // acknowledged notification over the event channel — so send() must + // resolve promptly instead of hanging on the SSE response wait (#1630). + // With a short response timeout, a request that *did* wait would reject. + const t = makeTransport( + { + events: () => openEventsResponse(), + send: () => jsonResponse({ ok: true }), + }, + { sseResponseTimeoutMs: 50 }, + ); + await t.start(); + await expect( + t.send({ + jsonrpc: "2.0", + id: "listen:0", + method: "subscriptions/listen", + params: {}, + }), + ).resolves.toBeUndefined(); + await t.close(); + }); + it("posts a message including relatedRequestId and succeeds", async () => { let sentBody: { relatedRequestId?: unknown } | undefined; const encoder = new TextEncoder(); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index e6b998123..ee0182ae0 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -19,7 +19,10 @@ import { join } from "node:path"; import { serve } from "@hono/node-server"; import type { ServerType } from "@hono/node-server"; import type pinoType from "pino"; -import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { + createRemoteApp, + requestIdForSendWait, +} from "@inspector/core/mcp/remote/node/server.js"; import { InMemorySecretStore, KeychainUnavailableError, @@ -108,6 +111,34 @@ function makeCapturingLogger(): { } describe("server.ts supplemental coverage", () => { + describe("requestIdForSendWait (#1630)", () => { + it("returns the id for an ordinary request", () => { + expect( + requestIdForSendWait({ jsonrpc: "2.0", id: 7, method: "tools/list" }), + ).toBe(7); + }); + + it("does not wait for a response on subscriptions/listen (long-lived stream)", () => { + expect( + requestIdForSendWait({ + jsonrpc: "2.0", + id: "listen:0", + method: "subscriptions/listen", + params: {}, + }), + ).toBeUndefined(); + }); + + it("returns undefined for a notification (no id)", () => { + expect( + requestIdForSendWait({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + ).toBeUndefined(); + }); + }); + describe("/api/log forwardLogEvent shapes", () => { let h: Harness; let records: Array<{ level: string; args: unknown[] }>; diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 3934b5f02..166665ba4 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -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 = { @@ -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; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e9d8033c3..e385dfc53 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -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 = 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 | 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 { + 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)}`, diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 23e845582..59d9b96bd 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -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 }; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 98ea0ba00..cf8d187e0 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -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; diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index fc6600e7d..0cec99ca9 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -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; diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 2a80a3f95..9f3ac4198 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -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; diff --git a/core/mcp/state/resourceSubscriptionsState.ts b/core/mcp/state/resourceSubscriptionsState.ts index a9f51b5d9..ab57e4214 100644 --- a/core/mcp/state/resourceSubscriptionsState.ts +++ b/core/mcp/state/resourceSubscriptionsState.ts @@ -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 = 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, + ): void => { + this.streamState = event.detail; + this.dispatchTypedEvent("streamStateChange", this.streamState); + }; const onSubscriptionsChange = ( event: TypedEventGeneric< @@ -87,7 +109,9 @@ export class ResourceSubscriptionsState extends TypedEventTarget [r.uri, r])); @@ -146,5 +186,6 @@ export class ResourceSubscriptionsState extends TypedEventTarget(state?.getSubscriptions() ?? []); + const [streamState, setStreamState] = + useState( + 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 }; } diff --git a/pr-screenshots/README.md b/pr-screenshots/README.md new file mode 100644 index 000000000..fcc923e81 --- /dev/null +++ b/pr-screenshots/README.md @@ -0,0 +1,61 @@ +# Resource subscriptions era fork (#1630) — proof screenshots + +End-to-end verification of the era fork against two real test servers +(`test-servers/configs/subscriptions-modern-http.json` on port 3220 and +`subscriptions-legacy-http.json` on 3221), driven in a real browser. + +## Modern era (2026-07-28) + +![Modern header badge](subscriptions-modern-header-badge.png) + +**`subscriptions-modern-header-badge.png`** — on the modern era the Subscriptions +section shows a single **stream-status badge in its accordion header** (next to the +count, so it stays visible while the section is collapsed): `● Listening` when the +`subscriptions/listen` stream is acknowledged, `● Reconnecting…` while it re-lists +after a drop, `● Stream ended` on graceful close — each explained in a tooltip. +Legacy connections show no badge. + +![Modern listen + acknowledged](subscriptions-modern-listen-acknowledged.png) + +**`subscriptions-modern-listen-acknowledged.png`** — subscribing on a +**Modern**-negotiated connection sends **`subscriptions/listen`** (long-lived → +`PENDING`) and the server answers with **`notifications/subscriptions/acknowledged`**. +No `resources/subscribe` is sent — the defining era-fork behavior. Two other +details: the connection era is shown once in the panel header (`Messages · MODERN`), +not per entry; and the `sub ⧉ listen:0` subscription-id tag rides the notification's +top line (beside the direction) so the method badge below gets the full column width +instead of truncating against the pin control. + +## Legacy era (contrast) + +![Legacy resources/subscribe](subscriptions-legacy-resources-subscribe.png) + +**`subscriptions-legacy-resources-subscribe.png`** — the same subscribe on a +**Legacy** connection sends **`resources/subscribe`** (Protocol view, LEGACY) and the +Subscriptions section shows **no stream badge and no header dot** (there is no +persistent stream on the legacy era). Legacy behavior is unchanged. + +![Legacy live update](subscriptions-legacy-live-update.png) + +**`subscriptions-legacy-live-update.png`** — calling the `update_resource` tool emits +**`notifications/resources/updated`** (server → client, Protocol view), and the +subscribed `resource_1` tile stamps its **last-updated time** (`2:53:17 PM`) — the +full subscribe → update → notify round-trip on the legacy era. + +## Notes + +- Modern subscribe in the browser is now prompt (`Connecting…` → `Listening`) and + stable: the web backend's `/api/mcp/send` and the browser remote transport used + to hold the `subscriptions/listen` request waiting for a JSON-RPC response that a + long-lived stream never sends, blocking `listen()` ~60s and then spuriously + driving the reconnect loop. Both sides now exempt `subscriptions/listen` from the + response-wait (its ack + notifications ride the SSE event channel), so the badge + no longer oscillates. (Some of the older screenshots here were captured before + that fix and show the transient `Reconnecting…`/`RECONNECTING…` state.) +- A tool-triggered `resources/updated` does **not** reach the modern + `subscriptions/listen` stream on the SDK's stateless modern leg (a tool call and + the listen stream are separate connections; `server.notification()` rides the + tool call's connection). This is an SDK server-side gap — the same class as the + logging showcase's stateless-notification caveat — independent of this PR's + client-side work. Hence the live-update proof is shown on the legacy era, where + the round-trip completes. diff --git a/pr-screenshots/subscriptions-legacy-live-update.png b/pr-screenshots/subscriptions-legacy-live-update.png new file mode 100644 index 000000000..83e31faf1 Binary files /dev/null and b/pr-screenshots/subscriptions-legacy-live-update.png differ diff --git a/pr-screenshots/subscriptions-legacy-resources-subscribe.png b/pr-screenshots/subscriptions-legacy-resources-subscribe.png new file mode 100644 index 000000000..e687a5cef Binary files /dev/null and b/pr-screenshots/subscriptions-legacy-resources-subscribe.png differ diff --git a/pr-screenshots/subscriptions-modern-header-badge.png b/pr-screenshots/subscriptions-modern-header-badge.png new file mode 100644 index 000000000..737a6b5c4 Binary files /dev/null and b/pr-screenshots/subscriptions-modern-header-badge.png differ diff --git a/pr-screenshots/subscriptions-modern-listen-acknowledged.png b/pr-screenshots/subscriptions-modern-listen-acknowledged.png new file mode 100644 index 000000000..3bffe69d7 Binary files /dev/null and b/pr-screenshots/subscriptions-modern-listen-acknowledged.png differ diff --git a/test-servers/configs/subscriptions-legacy-http.json b/test-servers/configs/subscriptions-legacy-http.json new file mode 100644 index 000000000..0aec13d6e --- /dev/null +++ b/test-servers/configs/subscriptions-legacy-http.json @@ -0,0 +1,14 @@ +{ + "serverInfo": { + "name": "subscriptions-legacy", + "version": "1.0.0" + }, + "tools": [{ "preset": "update_resource" }], + "resources": [{ "preset": "numbered_resources", "params": { "count": 3 } }], + "subscriptions": true, + "listChanged": { "resources": true }, + "transport": { + "type": "streamable-http", + "port": 3221 + } +} diff --git a/test-servers/configs/subscriptions-modern-http.json b/test-servers/configs/subscriptions-modern-http.json new file mode 100644 index 000000000..54331e894 --- /dev/null +++ b/test-servers/configs/subscriptions-modern-http.json @@ -0,0 +1,14 @@ +{ + "serverInfo": { + "name": "subscriptions-modern", + "version": "1.0.0" + }, + "resources": [{ "preset": "numbered_resources", "params": { "count": 3 } }], + "subscriptions": true, + "listChanged": { "resources": true }, + "transport": { + "type": "streamable-http", + "port": 3220, + "modern": true + } +}