Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ There are two common shapes:

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.

[`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`.
[`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.

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

Expand Down Expand Up @@ -460,7 +460,7 @@ Each example is standalone, runnable, and has its own README.
|---|---|
| [`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** |
| [`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` |
| [`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. |
| [`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. |
| [`examples/auth`](examples/auth) | Two-layer auth: SDK-native bearer middleware **or** custom HTTP middleware, both writing gRPC metadata for the upstream |

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

Expand Down
61 changes: 61 additions & 0 deletions examples/subscriptions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,63 @@ resources push from internal code and others need an external watch.
Return `nil` for push-path URIs (no-op) and open the external watch
only for URIs that need it.

## Serving stateless (protocol ≥ 2026-07-28, no session affinity)

`cmd/subscriptions-stateless` runs the same subscription wiring with

```go
protomcp.WithHTTPOptions(&mcp.StreamableHTTPOptions{
Stateless: true,
PropagateRequestCancellation: true,
})
```

Stateless mode is the shape for horizontally scaled servers behind a
plain round-robin load balancer — and it is the only mode in which the
Go SDK speaks protocol revision 2026-07-28, where `resources/subscribe`
is replaced by `subscriptions/listen`: one long-lived POST whose
response stream carries the notifications. Subscription state lives on
that connection, not in a server-side session map, so no affinity
mechanism is needed: any replica can serve any request, and the replica
holding a listen stream delivers to it. Your `SubscribeHandler` /
`UnsubscribeHandler` fire per URI exactly as in the other patterns —
`subscriptions/listen` routes through the same gate — and the push
side (`ResourceUpdated`) is unchanged; in a multi-replica deployment,
feed every replica from a shared event source so whichever one holds a
given stream can deliver.

**A dropped listen stream is not replaced, and the loss is silent.** On
go-sdk v1.7.0, streams on this protocol carry no SSE event IDs, and the
client abandons a POST stream whose connection dies without one instead
of retrying it; because `subscriptions/listen` is dispatched
fire-and-forget, the synthesized `request terminated without response`
error is discarded, so no error surfaces to the application. A later
`ClientSession.Subscribe` for the same URI is a no-op while the client
still believes it is subscribed — recovery is `Unsubscribe` (which
clears that client-side entry) followed by a fresh `Subscribe`, and any
replica can answer the new stream. A client that must survive
connection drops therefore needs a liveness signal that rides the
stream itself: a subscribed heartbeat resource the server touches on an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the heartbeat has to be sent on each watched URI's own stream, since ClientSession.Subscribe opens a separate subscriptions/listen POST for each URI, and a separate heartbeat subscription can keep arriving while watched URI's stream is already dead. Could we recommend either emitting a periodic update for every watched URI, or unconditionally Unsubscribe + Subscribe each URI, and update the same guidance in main.go?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right about the per-URI streams — Subscribe opens a dedicated subscriptions/listen POST per URI (client.go:1375, and the resourceSubs doc comment says "dedicated" outright), so a separate heartbeat subscription attests only itself. But pinning the recommended cycle in an e2e surfaced something worse: on v1.7.0 the Unsubscribe + Subscribe cycle can't work at all. Unsubscribe cancels the listen call, the client then sends notifications/cancelled without the SEP-2575 _meta envelope (cancelCall is the one send site that skips injectRequestMeta), a conformant server rejects it (-32602 / HTTP 400), and the client treats the failed send as fatal and hard-fails the whole connection — the re-Subscribe is dispatched fire-and-forget into the dead conn and vanishes silently, and canceling any in-flight call poisons the session the same way. Filed upstream as modelcontextprotocol/go-sdk#1212 with fix PR modelcontextprotocol/go-sdk#1213.

Guidance updated accordingly in 2b1b8ed: detection = the server emits a periodic update for every watched URI, so a missed beat on that URI's own stream marks exactly that stream dead; recovery = replace the session (Close + Connect + re-Subscribe each URI + re-read to reconcile) until the SDK fix lands. 3e9375c then implements the heartbeat in this example rather than just recommending it — watchHeartbeat refcounts watched URIs from the subscribe/unsubscribe gate and touches each on the -heartbeat interval — so main.go and the README say the same thing. The e2e now pins all three pieces: heartbeats arriving with no mutation at all, the Unsubscribe poisoning, and the reconnect recovery; the poisoning assertions flip to failing when go-sdk#1212 is fixed, flagging this guidance for revisit (the cheaper per-URI cycle becomes viable then).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: release-day adoption is tracked on our side — the go-sdk pin bump flips this PR's poisoning e2e (it asserts the broken behavior by design) and restores per-URI Unsubscribe + Subscribe cycling as the recommended recovery; #8's replay skip is version-locked to v1.7.0, so the same bump turns it into a hard assertion too.

interval, where a missed-heartbeat timeout marks the stream dead and
triggers the recovery above — or, more bluntly, an unconditional
periodic re-subscribe. Periodic re-reads of the watched resource are a
reconciliation fallback, not a liveness check: each read is its own
stateless POST and succeeds whether or not the listen stream is alive,
so polling bounds how stale a client can silently become, but an
unchanged resource reveals nothing and a dead stream goes undetected.

`PropagateRequestCancellation` ties each in-flight handler context to
its HTTP request, so a client that goes away mid-call cancels the
handler instead of leaving it running for nobody. (The SDK forces this
on for `subscriptions/listen` requests regardless — a listen handler
blocks until its request ends.)

The e2e suite in `cmd/subscriptions-stateless/main_test.go` pins the
whole contract: 2026-07-28 negotiated over plain HTTP, listen-based
delivery to concurrent clients, unsubscribe teardown, handler
cancellation on request abort, and the same endpoint still answering a
classic `initialize` from pre-2026 clients.

## Running the demos

```shell
Expand All @@ -203,6 +260,10 @@ go run ./examples/subscriptions/cmd/subscriptions-simple -addr :8080

# Pattern B: watch stream + authz (requires a bearer token)
go run ./examples/subscriptions/cmd/subscriptions -addr :8080

# Stateless: same push wiring, Stateless + PropagateRequestCancellation,
# subscriptions arrive via subscriptions/listen (protocol >= 2026-07-28)
go run ./examples/subscriptions/cmd/subscriptions-stateless -addr :8080
```

Point any MCP client at `http://localhost:8080`. For Pattern B,
Expand Down
189 changes: 189 additions & 0 deletions examples/subscriptions/cmd/subscriptions-stateless/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Command subscriptions-stateless runs the tasks-mcp server in
// stateless Streamable HTTP mode — the deployment shape for
// horizontally scaled servers behind a plain round-robin load
// balancer, with no session affinity anywhere.
//
// Stateless mode is also the only mode in which the Go SDK speaks
// protocol revision 2026-07-28, which replaces `resources/subscribe`
// with `subscriptions/listen`: one long-lived POST whose response
// stream carries the notifications. That flips where subscription
// state lives — on the connection, not in a server-side session map:
//
// - Any replica can serve any request; nothing routes on a session.
// - A subscription lives exactly as long as its listen stream. The
// replica holding the stream delivers `notifications/resources/
// updated` for it. A dropped stream is NOT re-issued: on go-sdk
// v1.7.0 these streams carry no SSE event IDs, the client abandons
// a listen POST that dies without one, and — because the listen
// call is dispatched fire-and-forget — no error surfaces to the
// application. A later Subscribe for the same URI is a no-op while
// the client still thinks it is subscribed; recovery is
// Unsubscribe then a fresh Subscribe, which any replica can
// answer. Clients that must survive drops need their own liveness
// signal (e.g. a subscribed heartbeat resource) to notice a dead
// stream.
// - SubscribeHandler / UnsubscribeHandler fire per URI exactly as in
// the legacy modes — `subscriptions/listen` routes through the same
// gate — so ACL checks carry over unchanged.
// - Each replica pushes ResourceUpdated for events it observes. With
// a shared event source (pub/sub, CDC, PG LISTEN) every replica
// sees every event, so whichever replica holds a given listen
// stream delivers to it.
//
// PropagateRequestCancellation ties every in-flight handler's context
// to its HTTP request: when the client goes away mid-call, the handler
// is canceled instead of running to completion for nobody. (For
// subscriptions/listen the SDK forces this on regardless — a listen
// handler blocks until its request ends, so it would otherwise never
// return.)
//
// Usage:
//
// go run ./examples/subscriptions/cmd/subscriptions-stateless # listens on 127.0.0.1:8080
// go run ./examples/subscriptions/cmd/subscriptions-stateless -addr :9000
package main

import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"

tasksserver "github.com/akuity/protomcp/examples/tasks/server"
tasksv1 "github.com/akuity/protomcp/pkg/api/gen/examples/tasks/v1"
"github.com/akuity/protomcp/pkg/protomcp"
)

func main() {
addr := flag.String("addr", "127.0.0.1:8080", "HTTP listen address for the MCP server")
flag.Parse()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
err := run(ctx, *addr)
stop()
if err != nil {
log.Fatalf("subscriptions-stateless: %v", err)
}
}

// newStatelessServer builds the MCP server in the shape this example
// exists to demonstrate. Split out so the e2e tests exercise exactly
// what the binary runs; nil handlers default to allow-all. The SDK
// calls the subscribe/unsubscribe gate per URI on both the legacy
// resources/subscribe path and the 2026-07-28 subscriptions/listen
// path, so this is where an ACL would live.
func newStatelessServer(
grpcClient tasksv1.TasksClient,
onSubscribe func(context.Context, *mcp.SubscribeRequest) error,
onUnsubscribe func(context.Context, *mcp.UnsubscribeRequest) error,
) *protomcp.Server {
if onSubscribe == nil {
onSubscribe = func(context.Context, *mcp.SubscribeRequest) error { return nil }
}
if onUnsubscribe == nil {
onUnsubscribe = func(context.Context, *mcp.UnsubscribeRequest) error { return nil }
}
srv := protomcp.New("tasks-subscriptions-stateless-mcp", "0.1.0",
protomcp.WithSDKOptions(&mcp.ServerOptions{
SubscribeHandler: onSubscribe,
UnsubscribeHandler: onUnsubscribe,
}),
protomcp.WithHTTPOptions(&mcp.StreamableHTTPOptions{
Stateless: true,
PropagateRequestCancellation: true,
}),
)
tasksv1.RegisterTasksMCPTools(srv, grpcClient)
tasksv1.RegisterTasksMCPResources(srv, grpcClient)
return srv
}

func run(ctx context.Context, addr string) error {
// 1. Start the Tasks gRPC service. Its OnChange hook fires on every
// CRUD mutation and becomes our push point below. In a real
// multi-replica deployment this would be a shared event source
// (pub/sub, CDC, PG LISTEN) consumed by every replica.
tSrv := tasksserver.New()
grpcClient, shutdownGRPC, err := startTasksGRPC(ctx, tSrv)
if err != nil {
return fmt.Errorf("start grpc: %w", err)
}
defer shutdownGRPC()

// 2. Build the stateless MCP server.
srv := newStatelessServer(grpcClient, nil, nil)

// 3. Push path: identical to the stateful examples. The SDK routes
// each ResourceUpdated to whichever live listen streams (or
// legacy sessions) subscribed to that URI on this replica.
tSrv.OnChange = func(id string) {
uri := "tasks://" + id
if nErr := srv.SDK().ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: uri}); nErr != nil {
log.Printf("ResourceUpdated %s: %v", uri, nErr)
}
}

httpSrv := &http.Server{
Addr: addr,
Handler: srv,
ReadHeaderTimeout: 5 * time.Second,
}

fmt.Printf("tasks-subscriptions-stateless-mcp listening on %s (stateless, protocol >= 2026-07-28 capable)\n", addr)
fmt.Println(" resources: tasks://{id} (read + list + push-on-mutation subscribe via subscriptions/listen)")
fmt.Println(" tools: Tasks_ListTasks, Tasks_GetTask, Tasks_CreateTask,")
fmt.Println(" Tasks_UpdateTask, Tasks_DeleteTask")

errCh := make(chan error, 1)
go func() {
if sErr := httpSrv.ListenAndServe(); sErr != nil && !errors.Is(sErr, http.ErrServerClosed) {
errCh <- sErr
return
}
errCh <- nil
}()

select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
case sErr := <-errCh:
return sErr
}
}

func startTasksGRPC(ctx context.Context, impl tasksv1.TasksServer) (tasksv1.TasksClient, func(), error) {
lis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0")
if err != nil {
return nil, nil, fmt.Errorf("listen: %w", err)
}
grpcSrv := grpc.NewServer()
tasksv1.RegisterTasksServer(grpcSrv, impl)
go func() { _ = grpcSrv.Serve(lis) }()

conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
grpcSrv.Stop()
_ = lis.Close()
return nil, nil, fmt.Errorf("dial: %w", err)
}

cleanup := func() {
_ = conn.Close()
grpcSrv.GracefulStop()
}
return tasksv1.NewTasksClient(conn), cleanup, nil
}
Loading