Fork resource subscriptions by protocol era: legacy keeps resources/subscribe; modern (2026-07-28) models subscriptions as a filter over one long-lived subscriptions/listen stream with reconnect-by-re-listen (capped backoff), an optimistic Connecting→Listening header badge, and a fix so the browser proxy no longer blocks listen() ~60s. Closes #1630.
This commit is contained in:
parent
a71e6d2012
commit
320daf2f67
37 changed files with 1656 additions and 90 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
|
||||
|
||||
const meta: Meta<typeof SubscriptionStreamBadge> = {
|
||||
title: "Elements/SubscriptionStreamBadge",
|
||||
component: SubscriptionStreamBadge,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SubscriptionStreamBadge>;
|
||||
|
||||
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" },
|
||||
};
|
||||
|
|
@ -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(<SubscriptionStreamBadge status="acknowledged" />);
|
||||
expect(screen.getByText("Listening")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a labelled reconnecting badge", () => {
|
||||
renderWithMantine(<SubscriptionStreamBadge status="reconnecting" />);
|
||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a labelled ended badge", () => {
|
||||
renderWithMantine(<SubscriptionStreamBadge status="ended" />);
|
||||
expect(screen.getByText("Stream ended")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Tooltip label={tooltip} multiline w={260} withArrow>
|
||||
<Badge variant="dot" color={color}>
|
||||
{label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
<Box w={340}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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(<ProtocolEntry {...baseProps} entry={discoverEntry} />);
|
||||
expect(screen.getByText("server/discover")).toBeInTheDocument();
|
||||
expect(screen.getByText("modern")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not flag an ordinary frame as modern", () => {
|
||||
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
|
||||
// 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(
|
||||
<ProtocolEntry
|
||||
{...baseProps}
|
||||
entry={subscriptionNotificationEntry}
|
||||
embedded
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("sub-abc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the modern badges in the embedded compact layout", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolEntry {...baseProps} entry={inputRequiredEntry} embedded />,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</Badge>
|
||||
);
|
||||
const modernFrameBadge = isModern && (
|
||||
<ModernFrameBadge>modern</ModernFrameBadge>
|
||||
);
|
||||
const subscriptionBadge = subscriptionId && (
|
||||
<SubscriptionCluster>
|
||||
<SubscriptionLabel>sub</SubscriptionLabel>
|
||||
|
|
@ -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}
|
||||
</ControlsCluster>
|
||||
</HeaderRow>
|
||||
<HeaderRow>
|
||||
<HeaderCluster flex={1}>
|
||||
<MethodBadge method={method} />
|
||||
{modernFrameBadge}
|
||||
{subscriptionBadge}
|
||||
{target && (
|
||||
<>
|
||||
{resourceUri && <CopyButton value={resourceUri} />}
|
||||
|
|
@ -395,7 +384,6 @@ export function ProtocolEntry({
|
|||
{directionBadge}
|
||||
<MethodBadge method={method} />
|
||||
{specErrorBadge}
|
||||
{modernFrameBadge}
|
||||
{subscriptionBadge}
|
||||
{target && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ControlledResourceControls
|
||||
protocolEra="modern"
|
||||
subscriptionStreamState={activeAck}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<ControlledResourceControls subscriptionStreamState={activeAck} />,
|
||||
);
|
||||
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no stream chrome on the modern era when the stream is inactive", () => {
|
||||
renderWithMantine(
|
||||
<ControlledResourceControls
|
||||
protocolEra="modern"
|
||||
subscriptionStreamState={{
|
||||
active: false,
|
||||
status: "ended",
|
||||
honoredUris: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ControlledResourceControls
|
||||
protocolEra="modern"
|
||||
subscriptionStreamState={activeAck}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
)}
|
||||
>
|
||||
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
|
||||
{formatSectionCount(
|
||||
"Subscriptions",
|
||||
filteredSubscriptions.length,
|
||||
)}
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text span>
|
||||
{formatSectionCount(
|
||||
"Subscriptions",
|
||||
filteredSubscriptions.length,
|
||||
)}
|
||||
</Text>
|
||||
{streamStatus && (
|
||||
<SubscriptionStreamBadge status={streamStatus} />
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string> = 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.)
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TestServerHttp> {
|
||||
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<string, unknown> | 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<string, unknown> | 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<McpSubscription> };
|
||||
modernSubscription: McpSubscription | null;
|
||||
modernListenGeneration: number;
|
||||
modernReconnectAttempts: number;
|
||||
subscribedResources: Set<string>;
|
||||
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<ReturnType<typeof makeFakeSub>> {
|
||||
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<McpSubscription>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue