fix(test-servers): describe and test after-first's resource-subscription counting
Copilot review on #2176. All three findings were the same gap seen from three angles: `"after-first"` counts *resource-subscription* listens, not listens, and both the README and the option's own doc comment said "the first listen". That distinction is load-bearing rather than incidental — the Inspector opens a listen at connect time whenever a list-change opt-in is live (#1920), so counting listens would spend the allowance there and refuse the user's very first Subscribe, leaving the documented repro describing a trace nobody sees. It is also what a reader of the option would otherwise get wrong. - README: say "the first listen that subscribes to a resource" in both places, and name the connect-time listen as the reason. - `ServerConfig.modern.neverAcknowledgeSubscriptions`: document the list-change exemption and contrast it with `true`. - Integration test for the mode itself, with every list-change opt-in on so the connect-time listen really is opened: it must be acknowledged without consuming the allowance, the first resource subscription acknowledged, the next refused with the stream left `active`. Closes #2097 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8535ae0c2f
commit
9136ab6268
3 changed files with 63 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue