Skip to content

Commit db86e72

Browse files
yhxlelejiachengxu
andauthored
Add stateless subscriptions example with listen-based e2e (#13)
Supersedes #9 (approved there; GitHub closed it when #8's base branch was deleted, and a rebased head cannot be reopened). Identical content rebased onto main after #8's squash-merge — the review threads and approval live on #9. --- Stacked on #8 (go-sdk v1.7.0 bump) — the follow-up it deferred. Adds `examples/subscriptions/cmd/subscriptions-stateless`: the tasks subscription wiring served with `StreamableHTTPOptions{Stateless: true, PropagateRequestCancellation: true}` — the deployment shape for horizontally scaled servers behind a plain round-robin load balancer, and the only mode in which the Go SDK speaks protocol revision 2026-07-28. ## What the e2e suite pins - **2026-07-28 negotiated over plain HTTP** via `server/discover` (the connect helper fails if a session ever falls back below it), with normal tool round-trips. - **Listen-based subscription delivery**: `resources/subscribe` doesn't exist on this path — `ClientSession.Subscribe` opens a per-URI `subscriptions/listen` stream, the server's `SubscribeHandler` still fires per URI (same ACL gate as the legacy path), and `ResourceUpdated` pushes flow back over the stream. Two concurrent clients on the same URI prove per-connection fan-out. - **Teardown**: `Unsubscribe` ends the listen stream, `UnsubscribeHandler` fires, and further mutations deliver nothing. - **`PropagateRequestCancellation`**: aborting the HTTP request mid-call cancels the in-flight handler context (proven with an instrumented blocking tool). - **Legacy coexistence**: the same stateless endpoint still answers a classic `initialize` from pre-2026 clients (echoing their requested version), and GET is rejected with 405/`Allow: POST` per the stateless contract. Subscription registration is asynchronous under `subscriptions/listen` (the client dispatches without awaiting), so delivery tests mutate in a poll loop until the notification arrives rather than mutating once — the suite passed three consecutive `-race -count=3` runs with no flakes. Docs: new "Serving stateless" section in `examples/subscriptions/README.md` (why no session affinity is needed: subscription state lives on the connection; a dropped stream is re-issued by the client to whichever replica answers next; feed every replica from a shared event source), plus main README examples-table and cmd-list entries. ## Verification - `go test -race -count=1 ./...` green (new suite: 6 tests, also run at `-count=3` for flake detection) - `golangci-lint run` 0 issues; `go mod tidy` no-op (no new dependencies) --------- Co-authored-by: Jiacheng Xu <xjcmaxwellcjx@gmail.com>
1 parent c3265ab commit db86e72

5 files changed

Lines changed: 937 additions & 2 deletions

File tree

.golangci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ linters:
5252
- fieldalignment # prioritise readability over struct-field packing
5353
misspell:
5454
locale: US
55+
ignore-rules:
56+
# MCP's own protocol vocabulary: the wire method is
57+
# notifications/cancelled and the SDK exports CancelledParams.
58+
- cancelled
5559
nakedret:
5660
max-func-lines: 30
5761
revive:

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ There are two common shapes:
383383

384384
2. **Wrap an external source.** Real handlers that start/stop upstream delivery per subscription (open a gRPC stream, run a PG `LISTEN`, subscribe to a Redis/Kafka topic, register a webhook). Best when events come from outside the process. Still call `ResourceUpdated` on each incoming event.
385385

386-
[`examples/subscriptions`](examples/subscriptions) ships both as runnable demos: `cmd/subscriptions-simple` for the push-from-write-path pattern (about 30 lines of wiring) and `cmd/subscriptions` for the external-source pattern, with per-principal subscribe authorization and a watcher wrapped in `protomcp.RetryLoop`.
386+
[`examples/subscriptions`](examples/subscriptions) ships all of this as runnable demos: `cmd/subscriptions-simple` for the push-from-write-path pattern (about 30 lines of wiring), `cmd/subscriptions` for the external-source pattern with per-principal subscribe authorization and a watcher wrapped in `protomcp.RetryLoop`, and `cmd/subscriptions-stateless` for the stateless serving shape where ≥ 2026-07-28 clients subscribe via `subscriptions/listen` with no session affinity anywhere.
387387

388388
### `protomcp.v1.prompt`, method option
389389

@@ -464,7 +464,7 @@ Each example is standalone, runnable, and has its own README.
464464
|---|---|
465465
| [`examples/greeter`](examples/greeter) | Tool primitive surface, unary + server-streaming RPCs, progress notifications with monotonic counter, **progress-token gRPC-metadata propagation**, `ToolErrorHandler`, `ToolResultProcessor` redaction, `ToolMiddleware` request mutation, SDK options pass-through, **`field_schema.exclude` schema masking round-trip** |
466466
| [`examples/tasks`](examples/tasks) | **Every declarative MCP primitive end-to-end.** Tools with `read_only` / `idempotent` / `destructive` hints + `OUTPUT_ONLY` stripping, **two `resource_template` annotations (`tasks://{id}`, `tags://{id}`)**, **a single `resource_list` that enumerates both types via `{type}://{id}` with `OffsetPagination`**, **prompts (`tasks_review`)**, **elicitation (confirm `DeleteTask`)**, plus `@example` markers and `enumDescriptions` on `TaskStatus` |
467-
| [`examples/subscriptions`](examples/subscriptions) | **User-wired resource subscriptions** on top of the Tasks resource template. Per-principal subscribe authorization in `SubscribeHandler`/`UnsubscribeHandler`, plus a watcher wrapped in `protomcp.RetryLoop` pushing `ResourceUpdated`. Race-tested. |
467+
| [`examples/subscriptions`](examples/subscriptions) | **User-wired resource subscriptions** on top of the Tasks resource template. Per-principal subscribe authorization in `SubscribeHandler`/`UnsubscribeHandler`, plus a watcher wrapped in `protomcp.RetryLoop` pushing `ResourceUpdated`. Also the **stateless serving shape** (`Stateless` + `PropagateRequestCancellation`): protocol ≥ 2026-07-28 with `subscriptions/listen`-delivered subscriptions and no session affinity. Race-tested. |
468468
| [`examples/auth`](examples/auth) | Two-layer auth: SDK-native bearer middleware **or** custom HTTP middleware, both writing gRPC metadata for the upstream |
469469

470470
Cmd directories inside each example hold the runnable binaries:
@@ -477,6 +477,7 @@ Cmd directories inside each example hold the runnable binaries:
477477
- [`examples/greeter/cmd/sdkopts`](examples/greeter/cmd/sdkopts), pass `mcp.ServerOptions` / `mcp.StreamableHTTPOptions`
478478
- [`examples/tasks/cmd/tasks`](examples/tasks/cmd/tasks), CRUD
479479
- [`examples/subscriptions/cmd/subscriptions`](examples/subscriptions/cmd/subscriptions), authorization-gated subscribe wiring
480+
- [`examples/subscriptions/cmd/subscriptions-stateless`](examples/subscriptions/cmd/subscriptions-stateless), stateless serving + `subscriptions/listen` delivery (protocol ≥ 2026-07-28)
480481
- [`examples/auth/cmd/auth`](examples/auth/cmd/auth), custom HTTP middleware → ctx → metadata
481482
- [`examples/auth/cmd/sdkauth`](examples/auth/cmd/sdkauth), MCP Go SDK's `auth.RequireBearerToken` → `TokenInfoFromContext` → metadata
482483

examples/subscriptions/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,100 @@ resources push from internal code and others need an external watch.
195195
Return `nil` for push-path URIs (no-op) and open the external watch
196196
only for URIs that need it.
197197

198+
## Serving stateless (protocol ≥ 2026-07-28, no session affinity)
199+
200+
`cmd/subscriptions-stateless` runs the same subscription wiring with
201+
202+
```go
203+
protomcp.WithHTTPOptions(&mcp.StreamableHTTPOptions{
204+
Stateless: true,
205+
PropagateRequestCancellation: true,
206+
})
207+
```
208+
209+
Stateless mode is the shape for horizontally scaled servers behind a
210+
plain round-robin load balancer — and it is the only mode in which the
211+
Go SDK speaks protocol revision 2026-07-28, where `resources/subscribe`
212+
is replaced by `subscriptions/listen`: one long-lived POST whose
213+
response stream carries the notifications. Subscription state lives on
214+
that connection, not in a server-side session map, so no affinity
215+
mechanism is needed: any replica can serve any request, and the replica
216+
holding a listen stream delivers to it. Your `SubscribeHandler` /
217+
`UnsubscribeHandler` fire per URI exactly as in the other patterns —
218+
`subscriptions/listen` routes through the same gate — and the push
219+
side (`ResourceUpdated`) is unchanged; in a multi-replica deployment,
220+
feed every replica from a shared event source so whichever one holds a
221+
given stream can deliver. One asymmetry to guard: a legacy
222+
`resources/subscribe` also reaches the gate on a stateless server, but
223+
its per-POST session dies with the response — the subscription could
224+
never deliver, and go-sdk tears the session down without firing
225+
`UnsubscribeHandler`, so any gate-side bookkeeping (the demo's
226+
heartbeat refcount included) leaks +1 with no matching -1. The demo
227+
rejects those legacy lifecycle RPCs outright
228+
(`requireListenScopedSubscription`): only `subscriptions/listen`
229+
subscriptions, whose params carry the SEP-2575 `_meta` protocol version,
230+
are accepted.
231+
232+
**A dropped listen stream is not replaced, and the loss is silent.** On
233+
go-sdk v1.7.0, streams on this protocol carry no SSE event IDs, and the
234+
client abandons a POST stream whose connection dies without one instead
235+
of retrying it; because `subscriptions/listen` is dispatched
236+
fire-and-forget, the synthesized `request terminated without response`
237+
error is discarded, so no error surfaces to the application. A later
238+
`ClientSession.Subscribe` for the same URI is a no-op while the client
239+
still believes it is subscribed (and a `Subscribe` whose initial listen
240+
call fails leaves the same stale entry behind), so a dead subscription
241+
cannot be repaired in place by subscribing again.
242+
243+
Detection has to ride each watched URI's own stream:
244+
`ClientSession.Subscribe` opens a separate `subscriptions/listen` POST
245+
per URI, so a dedicated heartbeat resource attests only its own stream
246+
and can keep beating while the stream for the URI you actually watch
247+
is already dead. To notice a dead stream, have the server emit a
248+
periodic update for every watched URI and treat a missed heartbeat as
249+
that stream's death — at the cost of one update (and, since a
250+
heartbeat is indistinguishable from a real change, one re-read) per
251+
URI per interval. The stateless demo implements this: `watchHeartbeat`
252+
refcounts watched URIs from the subscribe/unsubscribe gate and touches
253+
each one on the `-heartbeat` interval (default 30s, `0` disables). Periodic re-reads of the watched resource remain a
254+
reconciliation fallback, not a liveness check: each read is its own
255+
stateless POST and succeeds whether or not the listen stream is alive,
256+
so polling bounds how stale a client can silently become, but an
257+
unchanged resource reveals nothing and a dead stream goes undetected.
258+
259+
Recovery is session-scoped on go-sdk v1.7.0. The natural per-URI cycle
260+
`Unsubscribe` to clear the client-side entry, then a fresh
261+
`Subscribe` — does not survive contact with the implementation:
262+
canceling the listen call makes the client send a
263+
`notifications/cancelled` POST without the
264+
`_meta["io.modelcontextprotocol/protocolVersion"]` the 2026-07-28
265+
protocol requires on every message (`cancelCall` skips the
266+
`injectRequestMeta` step every other client send performs), the server
267+
rejects it (`-32602` over HTTP 400), and the client treats the failed
268+
send as fatal and permanently fails the connection. From that point
269+
every awaited call returns `connection closed` and every
270+
fire-and-forget send — a re-`Subscribe` included — vanishes silently;
271+
canceling any in-flight request poisons the session through the same
272+
path. Until that is fixed upstream, recover by replacing the session:
273+
close the poisoned `ClientSession`, `Connect` a fresh one (any replica
274+
answers), re-`Subscribe` every watched URI, and re-read each one to
275+
reconcile updates missed while dark. The e2e suite pins both halves —
276+
the poisoning and the reconnect recovery.
277+
278+
`PropagateRequestCancellation` ties each in-flight handler context to
279+
its HTTP request, so a client that goes away mid-call cancels the
280+
handler instead of leaving it running for nobody. (The SDK forces this
281+
on for `subscriptions/listen` requests regardless — a listen handler
282+
blocks until its request ends.)
283+
284+
The e2e suite in `cmd/subscriptions-stateless/main_test.go` pins the
285+
whole contract: 2026-07-28 negotiated over plain HTTP, listen-based
286+
delivery to concurrent clients, unsubscribe teardown, heartbeats
287+
arriving with no mutation at all, the Unsubscribe poisoning and its
288+
reconnect recovery, handler cancellation on request abort, and the
289+
same endpoint still answering a classic `initialize` from pre-2026
290+
clients.
291+
198292
## Running the demos
199293

200294
```shell
@@ -203,6 +297,11 @@ go run ./examples/subscriptions/cmd/subscriptions-simple -addr :8080
203297

204298
# Pattern B: watch stream + authz (requires a bearer token)
205299
go run ./examples/subscriptions/cmd/subscriptions -addr :8080
300+
301+
# Stateless: same push wiring, Stateless + PropagateRequestCancellation,
302+
# subscriptions arrive via subscriptions/listen (protocol >= 2026-07-28);
303+
# -heartbeat touches every watched URI on an interval for stream liveness
304+
go run ./examples/subscriptions/cmd/subscriptions-stateless -addr :8080 -heartbeat 30s
206305
```
207306

208307
Point any MCP client at `http://localhost:8080`. For Pattern B,

0 commit comments

Comments
 (0)