Skip to content

Commit cd7d80c

Browse files
mcp: Implement subscriptions/listen rpc (SEP-2575) (#1007)
Fixes #966
1 parent 3486f6b commit cd7d80c

10 files changed

Lines changed: 1368 additions & 104 deletions

File tree

internal/jsonrpc2/conn.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,12 +208,30 @@ type ConnectionConfig struct {
208208
Bind func(*Connection) Handler // required
209209
OnDone func() // optional
210210
OnInternalError func(error) // optional
211+
212+
// PropagateCancellation controls whether cancellation of the context
213+
// passed to [NewConnection] is observable by request handlers.
214+
//
215+
// By default (false), the connection wraps that context (see [notDone])
216+
// so handlers' Done channels do not fire when the connection's root
217+
// context is cancelled. Cancellation of an in-flight handler is then
218+
// expected to flow only through the jsonrpc2 layer's explicit channels
219+
// (the [Preempter] reacting to the peer's cancel notification, or a
220+
// transport read/write failure cancelling every in-flight request).
221+
//
222+
// Set this true when cancellation of the connection's context is itself
223+
// a meaningful signal that handlers should react to (for example, when
224+
// the connection is tied to a carrier that owns it and whose end means
225+
// the request is cancelled).
226+
PropagateCancellation bool // optional
211227
}
212228

213229
// NewConnection creates a new [Connection] object and starts processing
214230
// incoming messages.
215231
func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection {
216-
ctx = notDone{ctx}
232+
if !cfg.PropagateCancellation {
233+
ctx = notDone{ctx}
234+
}
217235

218236
c := &Connection{
219237
state: inFlightState{closer: cfg.Closer},
@@ -530,6 +548,14 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter
530548
ac.retire(&Response{ID: id, Error: err})
531549
}
532550
s.outgoingCalls = nil
551+
552+
// Cancel any incoming requests still in flight: with the reader gone we
553+
// cannot receive cancellation notifications, and likely cannot write a
554+
// response either, so parked handlers have nothing useful left to do.
555+
// Mirrors the equivalent cleanup on write failure.
556+
for _, r := range s.incomingByID {
557+
r.cancel()
558+
}
533559
})
534560
}
535561

@@ -724,9 +750,12 @@ func (c *Connection) write(ctx context.Context, msg Message) error {
724750
var err error
725751
// Fail writes immediately if the connection is shutting down.
726752
//
727-
// TODO(rfindley): should we allow cancellation notifications through? It
728-
// could be the case that writes can still succeed.
753+
// Allow outgoing "notifications" forwarded by the Notify method.
754+
// This will allow to send the cancelled notification when the client is shutting down.
729755
c.updateInFlight(func(s *inFlightState) {
756+
if req, ok := msg.(*Request); ok && !req.IsCall() && s.outgoingNotifications > 0 {
757+
return
758+
}
730759
err = s.shuttingDown(ErrServerClosing)
731760
})
732761
if err == nil {
@@ -771,6 +800,15 @@ func (c *Connection) internalErrorf(format string, args ...any) error {
771800
}
772801

773802
// notDone is a context.Context wrapper that returns a nil Done channel.
803+
//
804+
// Request handlers' contexts are derived from the connection's root context,
805+
// which by default is wrapped in notDone so a transport-level cancellation
806+
// does not implicitly cancel every in-flight handler. Cancellation of an
807+
// in-flight handler is instead expected to flow only through the jsonrpc2
808+
// layer's explicit channels: the [Preempter] calling [Connection.Cancel] in
809+
// response to the peer's cancel notification, or the transport itself
810+
// failing (the read loop exits on EOF or a write fails) — both of which
811+
// cancel every in-flight incoming request in turn.
774812
type notDone struct{ ctx context.Context }
775813

776814
func (ic notDone) Value(key any) any {

mcp/client.go

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,27 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
300300
if hc, ok := cs.mcpConn.(clientConnection); ok {
301301
hc.sessionUpdated(cs.state)
302302
}
303+
subscribeParams := &SubscriptionsListenParams{}
304+
if c.opts.ToolListChangedHandler != nil {
305+
subscribeParams.Notifications.ToolsListChanged = true
306+
}
307+
if c.opts.PromptListChangedHandler != nil {
308+
subscribeParams.Notifications.PromptsListChanged = true
309+
}
310+
if c.opts.ResourceListChangedHandler != nil {
311+
subscribeParams.Notifications.ResourcesListChanged = true
312+
}
313+
if subscribeParams.Notifications.ToolsListChanged ||
314+
subscribeParams.Notifications.PromptsListChanged ||
315+
subscribeParams.Notifications.ResourcesListChanged {
316+
// ClientSession.Close cancels the listenCtx context to send notifications/cancelled.
317+
listenCtx, cancelListen := context.WithCancel(context.Background())
318+
cs.listenCancel = cancelListen
319+
if err := cs.subscriptionsListen(listenCtx, subscribeParams); err != nil {
320+
cancelListen()
321+
return nil, fmt.Errorf("opening subscriptions/listen: %w", err)
322+
}
323+
}
303324
return cs, nil
304325
}
305326

@@ -414,6 +435,7 @@ type ClientSession struct {
414435
conn *jsonrpc2.Connection
415436
client *Client
416437
keepaliveCancel context.CancelFunc
438+
listenCancel context.CancelFunc
417439
mcpConn Connection
418440

419441
// No mutex is (currently) required to guard the session state, because it is
@@ -430,6 +452,15 @@ type ClientSession struct {
430452
// Pending URL elicitations waiting for completion notifications.
431453
pendingElicitationsMu sync.Mutex
432454
pendingElicitations map[string]chan struct{}
455+
456+
// resourceSubsMu guards resourceSubs.
457+
resourceSubsMu sync.Mutex
458+
// resourceSubs maps a subscribed resource URI to the cancel func of the
459+
// goroutine running its dedicated subscriptions/listen stream. Populated
460+
// only under SEP-2575; the legacy protocol routes Subscribe and
461+
// Unsubscribe straight to the resources/subscribe and resources/unsubscribe
462+
// RPCs and leaves this map untouched.
463+
resourceSubs map[string]context.CancelFunc
433464
}
434465

435466
type clientSessionState struct {
@@ -496,6 +527,10 @@ func (cs *ClientSession) Close() error {
496527
if cs.keepaliveCancel != nil {
497528
cs.keepaliveCancel()
498529
}
530+
if cs.listenCancel != nil {
531+
cs.listenCancel()
532+
}
533+
cs.cancelAllResourceSubscriptions()
499534
err := cs.conn.Close()
500535

501536
if cs.onClose != nil && cs.calledOnClose.CompareAndSwap(false, true) {
@@ -1083,6 +1118,7 @@ var clientMethodInfos = map[string]methodInfo{
10831118
notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification),
10841119
notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification),
10851120
notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK),
1121+
notificationSubscriptionsAck: newClientMethodInfo(clientMethod((*Client).callSubscriptionsAckHandler), notification|missingParamsOK),
10861122
}
10871123

10881124
func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo {
@@ -1279,17 +1315,91 @@ func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (
12791315
// Subscribe sends a "resources/subscribe" request to the server, asking for
12801316
// notifications when the specified resource changes.
12811317
func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) error {
1282-
_, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params)))
1283-
return err
1318+
if !cs.usesNewProtocol() {
1319+
_, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params)))
1320+
return err
1321+
}
1322+
if params == nil || params.URI == "" {
1323+
return fmt.Errorf("Subscribe: missing URI")
1324+
}
1325+
uri := params.URI
1326+
1327+
var listenCtx context.Context
1328+
cs.resourceSubsMu.Lock()
1329+
if _, exists := cs.resourceSubs[uri]; !exists {
1330+
var cancel context.CancelFunc
1331+
listenCtx, cancel = context.WithCancel(context.Background())
1332+
if cs.resourceSubs == nil {
1333+
cs.resourceSubs = make(map[string]context.CancelFunc)
1334+
}
1335+
cs.resourceSubs[uri] = cancel
1336+
}
1337+
cs.resourceSubsMu.Unlock()
1338+
if listenCtx == nil {
1339+
// Already subscribed to this URI
1340+
return nil
1341+
}
1342+
1343+
return cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{
1344+
Notifications: NotificationSubscriptions{
1345+
ResourceSubscriptions: []string{uri},
1346+
},
1347+
})
12841348
}
12851349

1286-
// Unsubscribe sends a "resources/unsubscribe" request to the server, cancelling
1287-
// a previous subscription.
1350+
// Unsubscribe cancels a previous [ClientSession.Subscribe] for params.URI.
1351+
//
1352+
// Under the legacy protocol it sends a "resources/unsubscribe" request.
1353+
//
1354+
// Under SEP-2575 it cancels the background "subscriptions/listen" stream
1355+
// opened by Subscribe for the URI. Unsubscribe is idempotent: calling it for
1356+
// a URI that is not currently subscribed is a no-op.
12881357
func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error {
1289-
_, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params)))
1358+
if !cs.usesNewProtocol() {
1359+
_, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params)))
1360+
return err
1361+
}
1362+
if params == nil || params.URI == "" {
1363+
return fmt.Errorf("Unsubscribe: missing URI")
1364+
}
1365+
cs.resourceSubsMu.Lock()
1366+
cancel, ok := cs.resourceSubs[params.URI]
1367+
delete(cs.resourceSubs, params.URI)
1368+
cs.resourceSubsMu.Unlock()
1369+
if ok {
1370+
cancel()
1371+
}
1372+
return nil
1373+
}
1374+
1375+
// cancelAllResourceSubscriptions cancels every active SEP-2575 resource
1376+
// subscription opened via Subscribe. The listen goroutines exit
1377+
// asynchronously as their contexts unwind. Called from Close.
1378+
func (cs *ClientSession) cancelAllResourceSubscriptions() {
1379+
cs.resourceSubsMu.Lock()
1380+
subs := cs.resourceSubs
1381+
cs.resourceSubs = nil
1382+
cs.resourceSubsMu.Unlock()
1383+
for _, cancel := range subs {
1384+
cancel()
1385+
}
1386+
}
1387+
1388+
// SubscriptionsListen opens a SEP-2575 "subscriptions/listen" stream.
1389+
//
1390+
// The server's first message on the stream is "notifications/subscriptions/acknowledged";
1391+
// subsequent opted-in notifications (e.g. tools/list_changed) are delivered through the
1392+
// usual handlers registered in [ClientOptions].
1393+
func (cs *ClientSession) subscriptionsListen(ctx context.Context, params *SubscriptionsListenParams) error {
1394+
params = injectRequestMeta(cs, params)
1395+
_, err := handleSend[*emptyResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params)))
12901396
return err
12911397
}
12921398

1399+
func (c *Client) callSubscriptionsAckHandler(context.Context, *ClientRequest[*SubscriptionsAcknowledgedParams]) (Result, error) {
1400+
return nil, nil
1401+
}
1402+
12931403
func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) {
12941404
if cs, ok := req.GetSession().(*ClientSession); ok {
12951405
cs.toolsCache.invalidate()

0 commit comments

Comments
 (0)