Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
56 changes: 56 additions & 0 deletions examples/subscriptions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,58 @@ 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 its own liveness signal — e.g. a

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 periodic re-read cannot be used as liveness signal here, since each ReadResource is a separate stateless POST and it can succeed even when the subscriptions/listen connection is dead. If resource stays unchanged, client has no way to notice the dead stream and will never run the Unsubscribe + Subscribe recovery, could we call this a polling/reconciliation fallback instead, and use a missed heartbeat timeout or periodic re-subscribe for liveness?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right again. A re-read is its own stateless POST, so it succeeds whether or not the listen stream is alive; with an unchanged resource it proves nothing, and even when the resource did change it detects the missed update after the fact, not the stream's health. Reworded in 760c89e: liveness now means a signal that rides the stream itself (a missed-heartbeat timeout on a subscribed heartbeat resource, or an unconditional periodic re-subscribe), and periodic re-reads are called what they are, a reconciliation fallback that bounds staleness but cannot detect a dead stream.

subscribed heartbeat resource the server touches on an interval, or
periodic re-reads of the watched resource — to detect a dead stream and
re-subscribe.

`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 +255,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