Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -4182,6 +4181,7 @@ function App() {
promptsListChanged={promptsListChanged}
resourcesListChanged={resourcesListChanged}
subscriptions={subscriptions}
subscriptionStreamState={subscriptionStreamState}
logs={logs}
tasks={tasks}
progressByTaskId={progressByTaskId}
Expand Down
Original file line number Diff line number Diff line change
@@ -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" },
};
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
},
};
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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 />,
Expand Down
22 changes: 5 additions & 17 deletions clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
extractMethod,
extractResultType,
extractSubscriptionId,
isModernFrame,
isReplayableProtocolMethod,
} from "../protocolUtils.js";

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -319,9 +308,6 @@ export function ProtocolEntry({
{statusLabel(status)}
</Badge>
);
const modernFrameBadge = isModern && (
<ModernFrameBadge>modern</ModernFrameBadge>
);
const subscriptionBadge = subscriptionId && (
<SubscriptionCluster>
<SubscriptionLabel>sub</SubscriptionLabel>
Expand Down Expand Up @@ -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} />}
Expand Down Expand Up @@ -395,7 +384,6 @@ export function ProtocolEntry({
{directionBadge}
<MethodBadge method={method} />
{specErrorBadge}
{modernFrameBadge}
{subscriptionBadge}
{target && (
<>
Expand Down
Loading
Loading