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
20 changes: 19 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,25 @@ v2/main/
│ │ # (`InspectorClientOptions.appElicitation`), and
│ │ # supplying one is what advertises the nested
│ │ # `elicitation` setting — so web opts in and
│ │ # cli/tui, which cannot host an App, do not)
│ │ # cli/tui, which cannot host an App, do not;
│ │ # subscriptionAck.ts: recognizing the modern
│ │ # `subscriptions/listen` a server answers with a
│ │ # bare graceful-closure result instead of
│ │ # acknowledging (#2097). The SDK gives that close
│ │ # NO code of its own — it is the same
│ │ # `SdkError(ConnectionClosed)` any pre-ack close
│ │ # carries — so the predicate reads the message,
│ │ # deliberately, since the alternative is refusing
│ │ # to retry a genuinely transient drop. The live
│ │ # check on that string is the integration test,
│ │ # which drives a real never-acknowledging server
│ │ # (`subscriptions-never-acknowledged-http.json`);
│ │ # an SDK that rephrases it fails there rather than
│ │ # silently reverting to the eight-retry loop. The
│ │ # status is `"never-acknowledged"`, NOT `"ended"`:
│ │ # `ended` means an EXPECTED close, and reading a
│ │ # deterministic conformance failure as one is the
│ │ # silence #2063 reported)
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
│ │ │ # server.json parser, strategy registry + well-known
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
| `subscriptions-never-acknowledged-http.json` | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) |

#### MCP Apps
Expand Down Expand Up @@ -457,6 +458,22 @@ The modern config deliberately **omits** `update_resource`. The SDK's modern leg

So the live update-notification round-trip is demonstrated on the legacy (stateful-session) server, and 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.

#### A listen that is never acknowledged

`subscriptions-never-acknowledged-http.json` serves the same three `numbered_resources` on the modern leg. It acknowledges the first `subscriptions/listen` **that subscribes to a resource** normally, and answers every resource-subscription listen after it with a bare JSON-RPC `result` instead of a `notifications/subscriptions/acknowledged`. A listen carrying only list-change opt-ins is always acknowledged — including the one the Inspector opens at connect time ([#1920](https://github.com/modelcontextprotocol/inspector/issues/1920)), which is why the counting is per *resource-subscription* listen: otherwise that connect-time listen spends the allowance before you have clicked anything and the very first Subscribe is refused. Connect with **Protocol Era = Modern**.

That result is not a malformed message. On the 2026-07-28 era the listen request is long-lived, and the `result` for its id is reserved as the [graceful-closure](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#graceful-closure) marker — so a server sending it up front is saying "acknowledged and closed in the same breath". It is deliberately bare, with no `resultType` discriminator, matching the payload from the original report rather than the spec's example.

Open the Resources tab and **Subscribe** to `resource_1`: an ordinary acknowledged stream, badge **Listening**. Now **Subscribe** to `resource_2`. Changing the filter re-lists, this one is refused, and:

- the subscribe fails with the reason spelled out — *"The server closed the subscription without acknowledging it… Not retrying"*;
- the Subscriptions badge turns an orange **Not acknowledged** and the panel carries the same sentence as a notice;
- the Protocol tab shows exactly **one** further `subscriptions/listen`.

On the broken build that second click produced eight `subscriptions/listen` requests with increasing ids over roughly a minute, the badge flickering `Reconnecting…` between them, and a final bare **Stream ended** that said nothing about why ([#2097](https://github.com/modelcontextprotocol/inspector/issues/2097), split out of [#2063](https://github.com/modelcontextprotocol/inspector/issues/2063), where it read as the Inspector "accepting" an invalid response). The condition is deterministic — the server answers the same way every time — so retrying it is noise, not recovery.

The first resource-subscription listen is acknowledged **so the badge is reachable at all**: it is gated on a live subscription, which a server refusing from the outset never lets you hold. That variant — refuse every listen, the literal shape in the report — is what the integration tests drive; it is the same code path, minus the badge. And `never-acknowledged` is a status of its own rather than **Stream ended** on purpose: `ended` covers the two *expected* closes (a server tearing an established stream down, and reconnection abandoned after repeated failures), and reading a deterministic conformance failure as either of them is the silence the issue is about.

#### Tasks, both eras

**Legacy** (`tasks-legacy-http.json`) advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets. Run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ export const Reconnecting: Story = {
export const Ended: Story = {
args: { status: "ended" },
};

/**
* The server answered `subscriptions/listen` with a bare JSON-RPC result — the
* graceful-closure marker — without ever acknowledging it (#2097). Distinct from
* `Ended` because the Inspector does not retry it.
*/
export const NeverAcknowledged: Story = {
args: { status: "never-acknowledged" },
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
import { subscriptionStreamPresentation } from "./subscriptionStreamUtils";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "@inspector/core/mcp/subscriptionAck.js";

describe("subscriptionStreamPresentation", () => {
it("maps each status to a color and label", () => {
Expand All @@ -21,6 +22,18 @@ describe("subscriptionStreamPresentation", () => {
color: "gray",
label: "Stream ended",
});
expect(subscriptionStreamPresentation("never-acknowledged")).toMatchObject({
color: "orange",
label: "Not acknowledged",
});
});

// The never-acknowledged tooltip must carry the *reason*, not just the generic
// stream explanation — that silence is what #2097 is about.
it("says why a never-acknowledged stream closed", () => {
expect(
subscriptionStreamPresentation("never-acknowledged").tooltip,
).toContain(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE);
});

it("explains the listen stream in every tooltip", () => {
Expand All @@ -29,6 +42,7 @@ describe("subscriptionStreamPresentation", () => {
"acknowledged",
"reconnecting",
"ended",
"never-acknowledged",
] as const) {
expect(subscriptionStreamPresentation(status).tooltip).toContain(
"subscriptions/listen stream",
Expand All @@ -52,4 +66,9 @@ describe("SubscriptionStreamBadge", () => {
renderWithMantine(<SubscriptionStreamBadge status="ended" />);
expect(screen.getByText("Stream ended")).toBeInTheDocument();
});

it("renders a labelled never-acknowledged badge", () => {
renderWithMantine(<SubscriptionStreamBadge status="never-acknowledged" />);
expect(screen.getByText("Not acknowledged")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "../../../../../../core/mcp/subscriptionAck.js";

export interface StreamPresentation {
/** Mantine palette color name conveying the status. */
Expand Down Expand Up @@ -39,6 +40,11 @@ const PRESENTATION: Record<
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.`,
},
"never-acknowledged": {
color: "orange",
label: "Not acknowledged",
tooltip: `${STREAM_INTRO} ${NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE} Re-subscribe to try again.`,
},
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type { InspectorResourceSubscription } from "@inspector/core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "@inspector/core/mcp/subscriptionAck.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
ResourceControls,
Expand Down Expand Up @@ -394,6 +395,37 @@ describe("ResourceControls", () => {
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
});

// A badge tooltip is not enough for this one: the server broke the listen
// contract and the user has to be told without hovering (#2097).
it("spells out a never-acknowledged close in the panel", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={{
active: true,
status: "never-acknowledged",
honoredUris: [],
}}
/>,
);
expect(screen.getByText("Not acknowledged")).toBeInTheDocument();
expect(
screen.getByText(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE),
).toBeInTheDocument();
});

it("shows no such notice while the stream is healthy", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
expect(
screen.queryByText(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE),
).not.toBeInTheDocument();
});
});

// A failed load is rendered above the list instead of leaving the panel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { Accordion, Group, Stack, Text, TextInput, Title } from "@mantine/core";
import {
Accordion,
Alert,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { RiArrowRightSLine } from "react-icons/ri";
import type {
Expand All @@ -10,6 +18,7 @@ import type {
InspectorResourceSubscription,
ResourceSubscriptionStreamState,
} from "../../../../../../core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "../../../../../../core/mcp/subscriptionAck.js";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
Expand All @@ -33,6 +42,15 @@ const TightRow = Group.withProps({ gap: "xs", wrap: "nowrap" });
// instead of overflowing the card (#1462).
const SidebarStack = Stack.withProps({ gap: "sm", flex: 1, mih: 0 });

// The never-acknowledged close (#2097) is the one stream status whose reason has
// to be readable without hovering the badge: it is a server-conformance problem
// the user has to act on, not a lifecycle event they can wait out.
const StreamNotice = Alert.withProps({
color: "orange",
variant: "light",
title: "Subscription not acknowledged",
});

const SearchInput = TextInput.withProps({
flex: 1,
placeholder: "Search...",
Expand Down Expand Up @@ -358,6 +376,11 @@ export function ResourceControls({
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{streamStatus === "never-acknowledged" && (
<StreamNotice>
{NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE}
</StreamNotice>
)}
{filteredSubscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.resource.uri}
Expand Down
100 changes: 100 additions & 0 deletions clients/web/src/test/core/mcp/subscriptionAck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import {
isNeverAcknowledgedSubscriptionClose,
subscriptionFailureMessage,
NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE,
} from "@inspector/core/mcp/subscriptionAck.js";

/**
* Unit coverage of the never-acknowledged predicate (#2097). The *live* check —
* that the SDK still phrases this rejection the way the predicate expects — is
* the integration test in `inspectorClient-subscriptions-era.test.ts`, which
* drives a real server answering `subscriptions/listen` with a bare result.
*/
describe("isNeverAcknowledgedSubscriptionClose (#2097)", () => {
const neverAcknowledged = () =>
new SdkError(
SdkErrorCode.ConnectionClosed,
"subscriptions/listen: server closed the subscription gracefully before acknowledging",
);

it("matches the SDK's never-acknowledged close", () => {
expect(isNeverAcknowledgedSubscriptionClose(neverAcknowledged())).toBe(
true,
);
});

// The sibling rejection carries the SAME code, which is exactly why the
// predicate has to read the message: a stream that closed for some other
// reason before acknowledgement may well succeed on a retry.
it("does not match a generic pre-ack close", () => {
expect(
isNeverAcknowledgedSubscriptionClose(
new SdkError(
SdkErrorCode.ConnectionClosed,
"subscriptions/listen closed before the server acknowledged",
),
),
).toBe(false);
});

it("does not match another SDK error code carrying the same words", () => {
expect(
isNeverAcknowledgedSubscriptionClose(
new SdkError(
SdkErrorCode.RequestTimeout,
"server closed the subscription gracefully before acknowledging",
),
),
).toBe(false);
});

it("does not match a plain Error or a non-error value", () => {
expect(
isNeverAcknowledgedSubscriptionClose(
new Error(
"server closed the subscription gracefully before acknowledging",
),
),
).toBe(false);
expect(isNeverAcknowledgedSubscriptionClose(undefined)).toBe(false);
expect(isNeverAcknowledgedSubscriptionClose("closed")).toBe(false);
});
});

describe("subscriptionFailureMessage (#2097)", () => {
it("reports the explanation for the never-acknowledged close", () => {
expect(
subscriptionFailureMessage(
new SdkError(
SdkErrorCode.ConnectionClosed,
"subscriptions/listen: server closed the subscription gracefully before acknowledging",
),
),
).toBe(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE);
});

// Every other failure keeps its own message — that text is the diagnostic,
// and replacing it would trade one silence for another.
it("passes any other failure through unchanged", () => {
expect(subscriptionFailureMessage(new Error("socket hang up"))).toBe(
"socket hang up",
);
expect(subscriptionFailureMessage("not an Error")).toBe("not an Error");
});
});

describe("NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE", () => {
// The whole defect was silence, so the copy has to name the wire event, the
// rule it broke, and the fact that nothing further will be attempted.
it("names the notification, the result semantics, and the no-retry decision", () => {
expect(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE).toContain(
"notifications/subscriptions/acknowledged",
);
expect(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE).toContain(
"graceful closure",
);
expect(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE).toContain("Not retrying");
});
});
Loading