Skip to content
Open
Show file tree
Hide file tree
Changes from all 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?

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