mcp: Implement subscriptions/listen rpc (SEP-2575) (#1007)

Fixes #966
This commit is contained in:
Guglielmo Colombo 2026-06-22 18:57:36 +02:00 • committed by GitHub
commit cd7d80c591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -12,6 +12,8 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"runtime"
@ -2449,3 +2451,791 @@ func TestSetErrorPreservesContent(t *testing.T) {
}
var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{})}
// runSubscriptionsListenTest exercises the SEP-2575 auto-listen flow end-to-end
// against the supplied transport pair. It captures every notification and the
// acknowledgment the client sees, then asserts:
//
// - the auto-listen issued by Client.Connect is acknowledged with a tagged
// subscription ID;
// - tool and prompt list-changed notifications are delivered to the matching
// handlers, each carrying the same subscription ID as the ack;
// - the subscription persists across multiple unrelated changes;
// - cs.Close() ends the subscription and further changes don't deliver.
func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct Transport, events chan subListenEvent) {
t.Helper()
ctx, topCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer topCancel()
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
waitFor := func(kind string) subListenEvent {
t.Helper()
select {
case e := <-events:
if e.kind != kind {
t.Fatalf("got event %q (id=%s), want kind %q", e.kind, e.id, kind)
}
return e
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for %q event", kind)
return subListenEvent{}
}
}
expectNoEvent := func(d time.Duration) {
t.Helper()
select {
case e := <-events:
t.Fatalf("unexpected event %q (id=%s)", e.kind, e.id)
case <-time.After(d):
}
}
ack := waitFor("ack")
if ack.id == "" {
t.Fatalf("acknowledgment missing subscription ID")
}
server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil)
if e := waitFor("tool"); e.id != ack.id {
t.Errorf("first tool notif id = %s, want %s", e.id, ack.id)
}
server.AddPrompt(&Prompt{Name: "p2"}, nil)
if e := waitFor("prompt"); e.id != ack.id {
t.Errorf("first prompt notif id = %s, want %s", e.id, ack.id)
}
server.AddTool(&Tool{Name: "t3", InputSchema: &jsonschema.Schema{Type: "object"}}, nil)
if e := waitFor("tool"); e.id != ack.id {
t.Errorf("second tool notif id = %s, want %s", e.id, ack.id)
}
server.AddPrompt(&Prompt{Name: "p3"}, nil)
if e := waitFor("prompt"); e.id != ack.id {
t.Errorf("second prompt notif id = %s, want %s", e.id, ack.id)
}
expectNoEvent(notificationDelay * 5)
cs.Close()
time.Sleep(50 * time.Millisecond)
server.AddTool(&Tool{Name: "t4", InputSchema: &jsonschema.Schema{Type: "object"}}, nil)
server.AddPrompt(&Prompt{Name: "p4"}, nil)
expectNoEvent(notificationDelay * 20)
}
type subListenEvent struct {
kind string // "ack", "tool", "prompt"
id string // subscription ID from _meta, stringified for cross-encoding equality
}
// newSubListenClient returns a client wired to push every ack and every
// list-changed notification it receives into events, tagged with the kind
// and the subscription ID extracted from _meta.
func newSubListenClient(events chan subListenEvent) *Client {
asEvent := func(kind string, raw any) subListenEvent {
return subListenEvent{kind, fmt.Sprint(raw)}
}
c := NewClient(testImpl, &ClientOptions{
ToolListChangedHandler: func(_ context.Context, req *ToolListChangedRequest) {
events <- asEvent("tool", req.Params.Meta[MetaKeySubscriptionID])
},
PromptListChangedHandler: func(_ context.Context, req *PromptListChangedRequest) {
events <- asEvent("prompt", req.Params.Meta[MetaKeySubscriptionID])
},
})
c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == notificationSubscriptionsAck {
if cr, ok := req.(*ClientRequest[*SubscriptionsAcknowledgedParams]); ok && cr.Params != nil {
events <- asEvent("ack", cr.Params.Meta[MetaKeySubscriptionID])
}
}
return next(ctx, method, req)
}
})
return c
}
func newSubListenServer() *Server {
s := NewServer(testImpl, nil)
AddTool(s, &Tool{Name: "t1"}, sayHi)
s.AddPrompt(&Prompt{Name: "p1"}, nil)
return s
}
func enableNewProtocol(t *testing.T) {
t.Helper()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
}
// TestSubscriptionsListen_InMemory exercises the listen flow over the
// session-shared in-memory transport (semantically equivalent to STDIO).
// Cancellation here propagates via notifications/cancelled.
func TestSubscriptionsListen_InMemory(t *testing.T) {
enableNewProtocol(t)
events := make(chan subListenEvent, 64)
server := newSubListenServer()
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
runSubscriptionsListenTest(t, newSubListenClient(events), server, ct, events)
}
// TestSubscriptionsListen_Streamable exercises the listen flow over a
// stateless HTTP server (SEP-2575). Each listen rides its own SSE response
// stream; cs.Close() tears it down.
func TestSubscriptionsListen_Streamable(t *testing.T) {
enableNewProtocol(t)
events := make(chan subListenEvent, 64)
server := newSubListenServer()
handler := NewStreamableHTTPHandler(
func(*http.Request) *Server { return server },
&StreamableHTTPOptions{Stateless: true},
)
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
runSubscriptionsListenTest(t, newSubListenClient(events), server,
&StreamableClientTransport{Endpoint: httpServer.URL}, events)
}
// TestSubscriptionsListen_NoHandlersNoListen verifies that a new-protocol
// client without any list-changed handlers registered does not open an
// auto-listen on connect, and therefore does not receive any acknowledgment
// or downstream notifications.
func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) {
enableNewProtocol(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
events := make(chan subListenEvent, 8)
server := newSubListenServer()
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
c := NewClient(testImpl, nil)
c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == notificationSubscriptionsAck {
events <- subListenEvent{"ack", ""}
}
return next(ctx, method, req)
}
})
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil)
select {
case e := <-events:
t.Fatalf("unexpected event %q on no-handler client", e.kind)
case <-time.After(notificationDelay * 10):
}
}
// resourceSubServer builds a server that advertises resource subscriptions
// and records every Subscribe/Unsubscribe handler invocation through chans.
func resourceSubServer(t *testing.T, subCh, unsubCh chan string) *Server {
t.Helper()
s := NewServer(testImpl, &ServerOptions{
SubscribeHandler: func(_ context.Context, r *SubscribeRequest) error {
subCh <- r.Params.URI
return nil
},
UnsubscribeHandler: func(_ context.Context, r *UnsubscribeRequest) error {
unsubCh <- r.Params.URI
return nil
},
})
s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil)
return s
}
// resourceSubEvent is one delivered notifications/resources/updated.
type resourceSubEvent struct {
uri string
id string // _meta subscription ID, stringified
}
// TestResourceSubscriptionsSEP2575_Streamable verifies the Subscribe ->
// ResourceUpdated path on a stateless Streamable HTTP server.
//
// Caveat: per-subscription Unsubscribe is intentionally NOT verified here.
// In stateless Streamable HTTP mode the subscriptions/listen handler blocks
// on its request context, and neither the HTTP POST disconnect nor the
// separate notifications/cancelled POST currently propagates to that
// handler's context. The handler only unwinds when the server next attempts
// a write to the (now-dead) SSE stream and the writeErr branch in the
// jsonrpc2 layer cancels the in-flight request. To keep the test
// hermetic we therefore trigger a write at the end by adding a resource,
// which fires notifications/resources/list_changed on the auto-listen path
// (if any) and on the per-URI listen, causing the listen handler to unwind.
// The spec-correct fix is to plumb the POST's request context down to the
// subscriptionsListen handler so HTTP disconnect is observed directly; this
// is tracked separately.
func TestResourceSubscriptions_Streamable(t *testing.T) {
enableNewProtocol(t)
subCh := make(chan string, 8)
unsubCh := make(chan string, 8)
events := make(chan resourceSubEvent, 16)
server := resourceSubServer(t, subCh, unsubCh)
handler := NewStreamableHTTPHandler(
func(*http.Request) *Server { return server },
&StreamableHTTPOptions{Stateless: true},
)
httpServer := httptest.NewServer(mustNotPanic(t, handler))
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
c := NewClient(testImpl, &ClientOptions{
ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) {
id := ""
if req.Params != nil && req.Params.Meta != nil {
id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID])
}
events <- resourceSubEvent{uri: req.Params.URI, id: id}
},
})
cs, err := c.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL},
&ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("subscribe r1: %v", err)
}
select {
case got := <-subCh:
if got != "file:///r1" {
t.Fatalf("got URI %q, want %q", got, "file:///r1")
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for SubscribeHandler")
}
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"})
select {
case e := <-events:
if e.uri != "file:///r1" {
t.Fatalf("got URI %q, want %q", e.uri, "file:///r1")
}
if e.id == "" || e.id == "<nil>" {
t.Fatalf("missing subscription ID on update (got %q)", e.id)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for resource update")
}
// See test header comment for the explanation of this teardown ritual:
// close the client, then drop server-side TCP, then drive a write that
// will fail (any extra ResourceUpdated for our URI), to unblock the
// in-flight listen handler so httpServer.Close can return.
_ = cs.Close()
httpServer.CloseClientConnections()
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"})
httpServer.Close()
}
// TestResourceSubscriptions_InMemory mirrors TestResourceSubscriptions_Streamable
// over an in-memory (stdio-equivalent) transport: the per-URI Subscribe path
// uses notifications/cancelled rather than HTTP disconnect for teardown.
func TestResourceSubscriptions_InMemory(t *testing.T) {
enableNewProtocol(t)
subCh := make(chan string, 8)
unsubCh := make(chan string, 8)
events := make(chan resourceSubEvent, 16)
server := resourceSubServer(t, subCh, unsubCh)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c := NewClient(testImpl, &ClientOptions{
ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) {
id := ""
if req.Params != nil && req.Params.Meta != nil {
id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID])
}
events <- resourceSubEvent{uri: req.Params.URI, id: id}
},
})
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("subscribe r1: %v", err)
}
waitURI := func(ch chan string, want string) {
t.Helper()
select {
case got := <-ch:
if got != want {
t.Fatalf("got URI %q, want %q", got, want)
}
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for URI %q", want)
}
}
waitURI(subCh, "file:///r1")
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"})
select {
case e := <-events:
if e.uri != "file:///r1" {
t.Fatalf("got URI %q, want %q", e.uri, "file:///r1")
}
if e.id == "" || e.id == "<nil>" {
t.Fatalf("missing subscription ID on update (got %q)", e.id)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for resource update")
}
// On stdio/in-memory, Unsubscribe sends notifications/cancelled which
// reaches the server preempter, cancels the listen handler ctx, and
// triggers the UnsubscribeHandler.
if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("unsubscribe r1: %v", err)
}
waitURI(unsubCh, "file:///r1")
// Updates for an unsubscribed URI MUST NOT be delivered. Give the server
// a moment to process the cancellation first.
time.Sleep(50 * time.Millisecond)
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"})
select {
case e := <-events:
t.Fatalf("unexpected resource update after unsubscribe: %q (id=%s)", e.uri, e.id)
case <-time.After(notificationDelay * 10):
}
}
// TestResourceSubscriptions_Subscribe_Idempotent verifies that calling
// Subscribe twice for the same URI in the same session is a no-op for the
// second call: it returns nil without invoking SubscribeHandler again and
// without opening a second listen stream.
func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) {
enableNewProtocol(t)
subCh := make(chan string, 8)
unsubCh := make(chan string, 8)
server := resourceSubServer(t, subCh, unsubCh)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c := NewClient(testImpl, &ClientOptions{
ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) {},
})
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("first subscribe: %v", err)
}
select {
case got := <-subCh:
if got != "file:///r1" {
t.Fatalf("got URI %q, want %q", got, "file:///r1")
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for first SubscribeHandler")
}
// Second Subscribe for the same URI returns nil and does NOT fire
// SubscribeHandler again.
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("second subscribe should be no-op, got error: %v", err)
}
select {
case got := <-subCh:
t.Fatalf("duplicate Subscribe should not re-invoke SubscribeHandler (got %q)", got)
case <-time.After(notificationDelay * 10):
}
// Subsequent Unsubscribe still works (verifies the URI is tracked
// correctly even though the second Subscribe was a no-op).
if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("unsubscribe: %v", err)
}
select {
case <-unsubCh:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for UnsubscribeHandler")
}
}
// TestResourceSubscriptions_MultipleURIs verifies that two concurrent
// Subscribe calls on the same session each open their own independent listen
// stream with a distinct subscription ID. Unsubscribing one does not affect
// the other.
func TestResourceSubscriptions_MultipleURIs(t *testing.T) {
enableNewProtocol(t)
subCh := make(chan string, 8)
unsubCh := make(chan string, 8)
events := make(chan resourceSubEvent, 16)
server := resourceSubServer(t, subCh, unsubCh)
server.AddResource(&Resource{Name: "r2", URI: "file:///r2"}, nil)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c := NewClient(testImpl, &ClientOptions{
ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) {
id := ""
if req.Params != nil && req.Params.Meta != nil {
id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID])
}
events <- resourceSubEvent{uri: req.Params.URI, id: id}
},
})
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("subscribe r1: %v", err)
}
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r2"}); err != nil {
t.Fatalf("subscribe r2: %v", err)
}
// Each Subscribe MUST fire its own SubscribeHandler invocation.
gotURIs := map[string]bool{}
for range 2 {
select {
case got := <-subCh:
gotURIs[got] = true
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for SubscribeHandler")
}
}
if !gotURIs["file:///r1"] || !gotURIs["file:///r2"] {
t.Fatalf("missing SubscribeHandler invocation; got %v", gotURIs)
}
// Each update delivers exactly one event tagged with that URI's distinct
// subscription ID.
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"})
ev1 := <-events
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r2"})
ev2 := <-events
if ev1.uri != "file:///r1" || ev2.uri != "file:///r2" {
t.Fatalf("got URIs %q and %q, want r1 and r2", ev1.uri, ev2.uri)
}
if ev1.id == "" || ev2.id == "" {
t.Fatalf("missing subscription IDs: r1=%q r2=%q", ev1.id, ev2.id)
}
if ev1.id == ev2.id {
t.Fatalf("r1 and r2 should have distinct subscription IDs, both = %q", ev1.id)
}
// Unsubscribe r1 only. r2 keeps working.
if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("unsubscribe r1: %v", err)
}
select {
case got := <-unsubCh:
if got != "file:///r1" {
t.Fatalf("got unsubscribe URI %q, want %q", got, "file:///r1")
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for UnsubscribeHandler")
}
time.Sleep(50 * time.Millisecond) // let server-side cancellation settle
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"})
server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r2"})
// Only the r2 update should arrive.
select {
case e := <-events:
if e.uri != "file:///r2" || e.id != ev2.id {
t.Fatalf("post-unsubscribe got %q (id=%s), want r2 (id=%s)", e.uri, e.id, ev2.id)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for r2 update after r1 unsubscribe")
}
select {
case e := <-events:
t.Fatalf("unexpected event after r1 unsubscribe: %q (id=%s)", e.uri, e.id)
case <-time.After(notificationDelay * 10):
}
// Explicit Unsubscribe r2 to verify it still works independently.
if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r2"}); err != nil {
t.Fatalf("unsubscribe r2: %v", err)
}
select {
case <-unsubCh:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for r2 UnsubscribeHandler")
}
}
// TestSubscriptionsListen_MultipleSessions verifies that two concurrent
// client sessions on the same server are isolated: a list-changed
// notification is fanned out to BOTH sessions, each with its own subscription
// ID; closing one session does not affect deliveries to the other.
func TestSubscriptionsListen_MultipleSessions(t *testing.T) {
enableNewProtocol(t)
server := newSubListenServer()
// Open two clients on the same server.
open := func(t *testing.T) (*ClientSession, chan subListenEvent, *ServerSession) {
t.Helper()
events := make(chan subListenEvent, 16)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
c := newSubListenClient(events)
cs, err := c.Connect(context.Background(), ct,
&ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
return cs, events, ss
}
csA, evA, ssA := open(t)
defer ssA.Close()
csB, evB, ssB := open(t)
defer ssB.Close()
waitFor := func(ch chan subListenEvent, kind string) subListenEvent {
t.Helper()
select {
case e := <-ch:
if e.kind != kind {
t.Fatalf("got event %q, want %q", e.kind, kind)
}
return e
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for %q event", kind)
return subListenEvent{}
}
}
ackA := waitFor(evA, "ack")
ackB := waitFor(evB, "ack")
// Request IDs are per-connection, so both sessions may legitimately use
// the same ID number; we only require that each session's own
// notifications carry its own ack ID.
// A single change fans out to BOTH sessions, each tagged with that
// session's own ack ID.
server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil)
gotA := waitFor(evA, "tool")
gotB := waitFor(evB, "tool")
if gotA.id != ackA.id {
t.Errorf("session A: tool notif id=%s, want %s", gotA.id, ackA.id)
}
if gotB.id != ackB.id {
t.Errorf("session B: tool notif id=%s, want %s", gotB.id, ackB.id)
}
// Close A; B's subscription must keep delivering.
csA.Close()
time.Sleep(50 * time.Millisecond)
server.AddTool(&Tool{Name: "t3", InputSchema: &jsonschema.Schema{Type: "object"}}, nil)
gotB2 := waitFor(evB, "tool")
if gotB2.id != ackB.id {
t.Errorf("session B after A closed: tool notif id=%s, want %s", gotB2.id, ackB.id)
}
select {
case e := <-evA:
t.Fatalf("session A unexpected event after Close: %q", e.kind)
case <-time.After(notificationDelay * 5):
}
csB.Close()
}
// TestSubscriptionsListen_ResourceListChanged covers the resources/list_changed
// auto-listen branch (the other two list-changed types are covered by
// runSubscriptionsListenTest, but resources is not).
func TestSubscriptionsListen_ResourceListChanged(t *testing.T) {
enableNewProtocol(t)
events := make(chan subListenEvent, 16)
server := NewServer(testImpl, nil)
server.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
asEvent := func(kind string, raw any) subListenEvent {
return subListenEvent{kind, fmt.Sprint(raw)}
}
c := NewClient(testImpl, &ClientOptions{
ResourceListChangedHandler: func(_ context.Context, req *ResourceListChangedRequest) {
id := any(nil)
if req.Params != nil && req.Params.Meta != nil {
id = req.Params.Meta[MetaKeySubscriptionID]
}
events <- asEvent("resource", id)
},
})
c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == notificationSubscriptionsAck {
if cr, ok := req.(*ClientRequest[*SubscriptionsAcknowledgedParams]); ok && cr.Params != nil {
events <- asEvent("ack", cr.Params.Meta[MetaKeySubscriptionID])
}
}
return next(ctx, method, req)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
waitFor := func(kind string) subListenEvent {
t.Helper()
select {
case e := <-events:
if e.kind != kind {
t.Fatalf("got event %q, want %q", e.kind, kind)
}
return e
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for %q", kind)
return subListenEvent{}
}
}
ack := waitFor("ack")
if ack.id == "" {
t.Fatal("acknowledgment missing subscription ID")
}
server.AddResource(&Resource{Name: "r2", URI: "file:///r2"}, nil)
got := waitFor("resource")
if got.id != ack.id {
t.Errorf("resource notif id=%s, want %s", got.id, ack.id)
}
cs.Close()
}
// TestSubscriptionsListen_DisconnectScrubsMaps verifies that closing a
// session removes its entries from the server's three per-type subscription
// maps via Server.disconnect.
func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) {
enableNewProtocol(t)
events := make(chan subListenEvent, 16)
server := newSubListenServer()
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
c := newSubListenClient(events)
cs, err := c.Connect(context.Background(), ct,
&ClientSessionOptions{protocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
// Wait for the auto-listen to actually register on the server side.
select {
case e := <-events:
if e.kind != "ack" {
t.Fatalf("got %q, want ack", e.kind)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for ack")
}
// Server should now have this session in tools+prompts maps (the fixture
// client opts in to both via newSubListenClient).
server.mu.Lock()
if _, ok := server.toolChangeSubscriptions[ss]; !ok {
server.mu.Unlock()
t.Fatal("session missing from toolChangeSubscriptions before close")
}
if _, ok := server.promptChangeSubscriptions[ss]; !ok {
server.mu.Unlock()
t.Fatal("session missing from promptChangeSubscriptions before close")
}
server.mu.Unlock()
cs.Close()
ss.Close()
// Closures complete asynchronously on the server side.
deadline := time.Now().Add(5 * time.Second)
for {
server.mu.Lock()
_, inTool := server.toolChangeSubscriptions[ss]
_, inPrompt := server.promptChangeSubscriptions[ss]
_, inResource := server.resourceChangeSubscriptions[ss]
server.mu.Unlock()
if !inTool && !inPrompt && !inResource {
return
}
if time.Now().After(deadline) {
t.Fatalf("subscription maps not scrubbed after Close: tool=%v prompt=%v resource=%v",
inTool, inPrompt, inResource)
}
time.Sleep(10 * time.Millisecond)
}
}