diff --git a/README.md b/README.md index 0287ce3d..ccf9d600 100644 --- a/README.md +++ b/README.md @@ -460,7 +460,7 @@ So the live update-notification round-trip is demonstrated on the legacy (statef #### 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` normally and answers every one after it with a bare JSON-RPC `result` instead of a `notifications/subscriptions/acknowledged`. Connect with **Protocol Era = Modern**. +`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. @@ -472,7 +472,7 @@ Open the Resources tab and **Subscribe** to `resource_1`: an ordinary acknowledg 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 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. +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 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 index f2e08b13..d41f0da2 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -1106,6 +1106,54 @@ describe("resource subscriptions era fork (#1630)", () => { expect(int.modernReconnectAttempts).toBe(0); }); + /** + * The showcase fixture's mode, and the only shape that reaches the badge: + * the never-acknowledged status is gated on a live subscription, which a + * server refusing from the outset never lets you hold. + * + * The list-change exemption is the load-bearing half. The Inspector opens + * a listen at connect time whenever a list-change opt-in is live (#1920), + * so a mode that counted *listens* would spend its allowance there and + * refuse the user's very first Subscribe — leaving the documented repro + * describing something the trace does not show. + */ + it("acknowledges the first resource subscription, then refuses (after-first)", async () => { + const started = await startServer({ + neverAcknowledgeSubscriptions: "after-first", + }); + // Every list-change opt-in ON, so the connect-time listen really is + // opened — that is the listen the exemption has to let through. + const { connected, messages } = await connect(started.url, "modern", { + tools: true, + resources: true, + prompts: true, + }); + expect( + methodsSent(messages).filter((m) => m === "subscriptions/listen"), + ).toHaveLength(1); + + // First resource subscription: acknowledged, so the allowance was still + // unspent after the connect-time listen. + await expect( + connected.subscribeToResource(RESOURCE_URI), + ).resolves.toBeUndefined(); + expect(connected.getResourceSubscriptionStreamState()).toMatchObject({ + active: true, + status: "acknowledged", + }); + + // Second: refused. The first URI survives the rollback, so the stream + // stays `active` and the badge is rendered. + await expect( + connected.subscribeToResource(RESOURCE_URI_2), + ).rejects.toThrow(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE); + expect(connected.getResourceSubscriptionStreamState()).toMatchObject({ + active: true, + status: "never-acknowledged", + }); + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + }); + it("retries again after the user re-subscribes", async () => { // The flag is a fact about the last attempt, not a latch: a fresh // user-initiated subscribe must be allowed to reach the server again. diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 555fec15..85494d1f 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -707,10 +707,21 @@ export interface ServerConfig { * same breath". Used by the `subscriptions-never-acknowledged-http` * showcase; a conformant server never does this on an opening listen. * - * `"after-first"` acknowledges the first listen and refuses every one after - * it — the *reconnect* shape, and the only way to reach the Inspector's + * `"after-first"` acknowledges the first listen that **subscribes to a + * resource** and refuses every resource-subscription listen after it — the + * *reconnect* shape, and the only way to reach the Inspector's * never-acknowledged badge by hand (it is gated on a live subscription, * which a server refusing from the outset never lets you hold). + * + * A listen carrying no `resourceSubscriptions` is always acknowledged under + * this mode and does not consume the allowance. That exemption is what makes + * the mode reproducible: the Inspector already opens a listen at connect + * time when a list-change opt-in is live (#1920), so counting listens rather + * than resource-subscription listens would spend the allowance before the + * user clicks anything, refusing the very first Subscribe. + * + * `true` refuses every listen unconditionally, list-change-only ones + * included — the literal shape in the report. */ neverAcknowledgeSubscriptions?: boolean | "after-first"; };