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
11 changes: 11 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,17 @@ func Example_listChanged() {
}
```

To notify a single session instead, for example when only the tools visible to
that session have changed, call
[`ServerSession.NotifyToolListChanged`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyToolListChanged),
[`NotifyPromptListChanged`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyPromptListChanged),
or
[`NotifyResourceListChanged`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyResourceListChanged).
The capability rules above apply to them too. On `2026-07-28` and later
sessions the notification is sent once on each of the session's
[`subscriptions/listen`](protocol.md#subscriptions) streams that asked for it,
and not at all if there are none.

## Multi Round-Trip Requests

[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
Expand Down
11 changes: 11 additions & 0 deletions internal/docs/server.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,17 @@ client is never told.

%include ../../mcp/server_example_test.go listchanged -

To notify a single session instead, for example when only the tools visible to
that session have changed, call
[`ServerSession.NotifyToolListChanged`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyToolListChanged),
[`NotifyPromptListChanged`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyPromptListChanged),
or
[`NotifyResourceListChanged`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyResourceListChanged).
The capability rules above apply to them too. On `2026-07-28` and later
sessions the notification is sent once on each of the session's
[`subscriptions/listen`](protocol.md#subscriptions) streams that asked for it,
and not at all if there are none.

## Multi Round-Trip Requests

[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
Expand Down
164 changes: 135 additions & 29 deletions mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2834,7 +2834,7 @@ func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct
}

type subListenEvent struct {
kind string // "ack", "tool", "prompt"
kind string // "ack", "tool", "prompt", "resource"
id string // subscription ID from _meta, stringified for cross-encoding equality
}

Expand All @@ -2852,6 +2852,9 @@ func newSubListenClient(events chan subListenEvent) *Client {
PromptListChangedHandler: func(_ context.Context, req *PromptListChangedRequest) {
events <- asEvent("prompt", req.Params.Meta[MetaKeySubscriptionID])
},
ResourceListChangedHandler: func(_ context.Context, req *ResourceListChangedRequest) {
events <- asEvent("resource", req.Params.Meta[MetaKeySubscriptionID])
},
})
c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
Expand All @@ -2870,6 +2873,7 @@ func newSubListenServer() *Server {
s := NewServer(testImpl, nil)
AddTool(s, &Tool{Name: "t1"}, sayHi)
s.AddPrompt(&Prompt{Name: "p1"}, nil)
s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil)
return s
}

Expand Down Expand Up @@ -2988,6 +2992,129 @@ func TestSubscriptionsListen_MissingNotifications(t *testing.T) {
}
}

// TestNotifyListChanged_OnlyChosenSession verifies that each Notify*ListChanged
// method notifies only the session it is called on, on that session's listen and
// tagged with the listen's subscription ID.
func TestNotifyListChanged_OnlyChosenSession(t *testing.T) {
ctx := t.Context()
server := newSubListenServer()

// Open two clients on the same server.
open := func(t *testing.T) (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)
_, err = c.Connect(context.Background(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
return events, ss
}
evA, ssA := open(t)
defer ssA.Close()
evB, ssB := open(t)
defer ssB.Close()
ackA := waitSubListenEvent(t, evA, "ack")
waitSubListenEvent(t, evB, "ack")

for _, tc := range []struct {
kind string
notify func(context.Context, *ServerSession) error
}{
{"tool", func(ctx context.Context, ss *ServerSession) error { return ss.NotifyToolListChanged(ctx, nil) }},
{"prompt", func(ctx context.Context, ss *ServerSession) error { return ss.NotifyPromptListChanged(ctx, nil) }},
{"resource", func(ctx context.Context, ss *ServerSession) error { return ss.NotifyResourceListChanged(ctx, nil) }},
} {
t.Run(tc.kind, func(t *testing.T) {
if err := tc.notify(ctx, ssA); err != nil {
t.Fatalf("notify: %v", err)
}

gotA := waitSubListenEvent(t, evA, tc.kind)
if gotA.id != ackA.id {
t.Errorf("session A: %s notification id = %s, want %s", tc.kind, gotA.id, ackA.id)
}
select {
case e := <-evA:
t.Fatalf("session A received a second event: %q", e.kind)
case e := <-evB:
t.Fatalf("session B received unexpected event %q", e.kind)
case <-time.After(notificationDelay * 5):
}
})
}
}

// TestNotifyListChanged_WhenSent verifies that a legacy session receives the
// notification, and that nothing is sent when the server sets ListChanged to
// false or a 2026-07-28 session has no open listen.
func TestNotifyListChanged_WhenSent(t *testing.T) {
for _, tc := range []struct {
name string
opts *ServerOptions
version string
wantDelivered bool
}{
{
name: "legacy",
version: protocolVersion20251125,
wantDelivered: true,
},
{
name: "capability off",
opts: &ServerOptions{Capabilities: &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: false}}},
version: protocolVersion20251125,
},
{
name: "no listen",
version: protocolVersion20260728,
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := t.Context()
server := NewServer(testImpl, tc.opts)
AddTool(server, &Tool{Name: "t1"}, sayHi)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()

// No list-changed handlers, so a 2026-07-28 client opens no listen.
events := make(chan subListenEvent, 4)
client := NewClient(testImpl, nil)
client.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == notificationToolListChanged {
events <- subListenEvent{kind: "tool"}
}
return next(ctx, method, req)
}
})
if _, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: tc.version}); err != nil {
t.Fatalf("client connect: %v", err)
}
if err := ss.NotifyToolListChanged(ctx, nil); err != nil {
t.Fatalf("NotifyToolListChanged: %v", err)
}
if tc.wantDelivered {
waitSubListenEvent(t, events, "tool")
}
select {
case e := <-events:
t.Fatalf("unexpected event %q", e.kind)
case <-time.After(notificationDelay * 5):
}
})
}
}

// 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 {
Expand Down Expand Up @@ -3536,8 +3663,8 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) {
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 should now have this session in tools+prompts+resource maps
// (the fixture client opts in to all three via newSubListenClient).
server.mu.Lock()
if _, ok := server.toolChangeSubscriptions[ss]; !ok {
server.mu.Unlock()
Expand All @@ -3547,6 +3674,10 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) {
server.mu.Unlock()
t.Fatal("session missing from promptChangeSubscriptions before close")
}
if _, ok := server.resourceChangeSubscriptions[ss]; !ok {
server.mu.Unlock()
t.Fatal("session missing from resourceChangeSubscriptions before close")
}
server.mu.Unlock()

cs.Close()
Expand Down Expand Up @@ -4028,7 +4159,6 @@ func TestSubscriptionsListen_ListChangedOutlivesEarlierTeardown(t *testing.T) {
} {
t.Run(tc.kind, func(t *testing.T) {
server := newSubListenServer()
server.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil)

ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
Expand All @@ -4040,30 +4170,7 @@ func TestSubscriptionsListen_ListChangedOutlivesEarlierTeardown(t *testing.T) {
// A client with all three handlers, so that the auto-listen opened
// by Connect asks for every list-changed notification.
events := make(chan subListenEvent, 16)
notified := func(kind string, meta Meta) {
events <- subListenEvent{kind, fmt.Sprint(meta[MetaKeySubscriptionID])}
}
c := NewClient(testImpl, &ClientOptions{
ToolListChangedHandler: func(_ context.Context, req *ToolListChangedRequest) {
notified("tool", req.Params.Meta)
},
PromptListChangedHandler: func(_ context.Context, req *PromptListChangedRequest) {
notified("prompt", req.Params.Meta)
},
ResourceListChangedHandler: func(_ context.Context, req *ResourceListChangedRequest) {
notified("resource", req.Params.Meta)
},
})
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 {
notified("ack", cr.Params.Meta)
}
}
return next(ctx, method, req)
}
})
c := newSubListenClient(events)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
Expand Down Expand Up @@ -4148,7 +4255,6 @@ func TestSubscriptionsListen_EveryListenIsNotifiedOfListChanges(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
server := newSubListenServer()
server.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil)

ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
Expand Down
94 changes: 94 additions & 0 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,100 @@ func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNot
return handleNotify(ctx, notificationProgress, newServerRequest(ss, orZero[Params](params)))
}

// NotifyToolListChanged sends a "notifications/tools/list_changed"
// notification to the client of this session.
//
// The server already sends this notification to every session when tools are
// added or removed. Use this method to notify a single session, for example
// when only the tools visible to that session have changed.
//
// Nothing is sent if the server's capabilities set
// [ToolCapabilities.ListChanged] to false. A client on protocol version
// 2026-07-28 or later is notified once on each of its open subscriptions/listen
// streams that asked for tool list changes, and not at all if it has none.
//
// params may be nil.
func (ss *ServerSession) NotifyToolListChanged(ctx context.Context, params *ToolListChangedParams) error {
return ss.notifyListChanged(ctx, notificationToolListChanged, func() Params {
if params == nil {
return &ToolListChangedParams{}
}
p := *params
return &p
})
}

// NotifyPromptListChanged sends a "notifications/prompts/list_changed"
// notification to the client of this session. It is like
// [ServerSession.NotifyToolListChanged], but for prompts, and is governed by
// [PromptCapabilities.ListChanged].
func (ss *ServerSession) NotifyPromptListChanged(ctx context.Context, params *PromptListChangedParams) error {
return ss.notifyListChanged(ctx, notificationPromptListChanged, func() Params {
if params == nil {
return &PromptListChangedParams{}
}
p := *params
return &p
})
}

// NotifyResourceListChanged sends a "notifications/resources/list_changed"
// notification to the client of this session. It is like
// [ServerSession.NotifyToolListChanged], but for resources, and is governed by
// [ResourceCapabilities.ListChanged].
func (ss *ServerSession) NotifyResourceListChanged(ctx context.Context, params *ResourceListChangedParams) error {
return ss.notifyListChanged(ctx, notificationResourceListChanged, func() Params {
if params == nil {
return &ResourceListChangedParams{}
}
p := *params
return &p
})
}

// notifyListChanged implements the Notify*ListChanged methods for the given
// list-changed notification method.
//
// makeParams is called once per notification sent and must return a new value
// each time, because on newer protocol versions each notification carries the
// ID of the listen it is sent on in its _meta (see [injectMetaSubscriptionID]).
func (ss *ServerSession) notifyListChanged(ctx context.Context, method string, makeParams func() Params) error {
server := ss.server
if !server.shouldSendListChangedNotification(method) {
return nil
}
if ss.negotiatedLegacyProtocol() {
// Clear any request ID inherited from ctx, so that the notification
// goes out on the standalone stream.
ctx := context.WithValue(ctx, idContextKey{}, jsonrpc.ID{})
return handleNotify(ctx, method, newServerRequest(ss, makeParams()))
}

server.mu.Lock()
var ids []jsonrpc.ID
switch method {
case notificationToolListChanged:
ids = slices.Clone(server.toolChangeSubscriptions[ss])
case notificationPromptListChanged:
ids = slices.Clone(server.promptChangeSubscriptions[ss])
case notificationResourceListChanged:
ids = slices.Clone(server.resourceChangeSubscriptions[ss])
}
server.mu.Unlock()
var errs []error
for _, id := range ids {
params := makeParams()
injectMetaSubscriptionID(params, id)
// Associate the notification with the listen request, so that it is
// sent on that listen's stream.
ctx := context.WithValue(ctx, idContextKey{}, id)
if err := handleNotify(ctx, method, newServerRequest(ss, params)); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}

// notifySubscriptionAcked sends a "notifications/subscriptions/acknowledged"
// notification on the listen stream represented by this session, indicating
// the subscription filter the server accepted (SEP-2575).
Expand Down
Loading