parent
3486f6bcaf
commit
cd7d80c591
10 changed files with 1368 additions and 104 deletions
|
|
@ -208,12 +208,30 @@ type ConnectionConfig struct {
|
|||
Bind func(*Connection) Handler // required
|
||||
OnDone func() // optional
|
||||
OnInternalError func(error) // optional
|
||||
|
||||
// PropagateCancellation controls whether cancellation of the context
|
||||
// passed to [NewConnection] is observable by request handlers.
|
||||
//
|
||||
// By default (false), the connection wraps that context (see [notDone])
|
||||
// so handlers' Done channels do not fire when the connection's root
|
||||
// context is cancelled. Cancellation of an in-flight handler is then
|
||||
// expected to flow only through the jsonrpc2 layer's explicit channels
|
||||
// (the [Preempter] reacting to the peer's cancel notification, or a
|
||||
// transport read/write failure cancelling every in-flight request).
|
||||
//
|
||||
// Set this true when cancellation of the connection's context is itself
|
||||
// a meaningful signal that handlers should react to (for example, when
|
||||
// the connection is tied to a carrier that owns it and whose end means
|
||||
// the request is cancelled).
|
||||
PropagateCancellation bool // optional
|
||||
}
|
||||
|
||||
// NewConnection creates a new [Connection] object and starts processing
|
||||
// incoming messages.
|
||||
func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection {
|
||||
ctx = notDone{ctx}
|
||||
if !cfg.PropagateCancellation {
|
||||
ctx = notDone{ctx}
|
||||
}
|
||||
|
||||
c := &Connection{
|
||||
state: inFlightState{closer: cfg.Closer},
|
||||
|
|
@ -530,6 +548,14 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter
|
|||
ac.retire(&Response{ID: id, Error: err})
|
||||
}
|
||||
s.outgoingCalls = nil
|
||||
|
||||
// Cancel any incoming requests still in flight: with the reader gone we
|
||||
// cannot receive cancellation notifications, and likely cannot write a
|
||||
// response either, so parked handlers have nothing useful left to do.
|
||||
// Mirrors the equivalent cleanup on write failure.
|
||||
for _, r := range s.incomingByID {
|
||||
r.cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -724,9 +750,12 @@ func (c *Connection) write(ctx context.Context, msg Message) error {
|
|||
var err error
|
||||
// Fail writes immediately if the connection is shutting down.
|
||||
//
|
||||
// TODO(rfindley): should we allow cancellation notifications through? It
|
||||
// could be the case that writes can still succeed.
|
||||
// Allow outgoing "notifications" forwarded by the Notify method.
|
||||
// This will allow to send the cancelled notification when the client is shutting down.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if req, ok := msg.(*Request); ok && !req.IsCall() && s.outgoingNotifications > 0 {
|
||||
return
|
||||
}
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
})
|
||||
if err == nil {
|
||||
|
|
@ -771,6 +800,15 @@ func (c *Connection) internalErrorf(format string, args ...any) error {
|
|||
}
|
||||
|
||||
// notDone is a context.Context wrapper that returns a nil Done channel.
|
||||
//
|
||||
// Request handlers' contexts are derived from the connection's root context,
|
||||
// which by default is wrapped in notDone so a transport-level cancellation
|
||||
// does not implicitly cancel every in-flight handler. Cancellation of an
|
||||
// in-flight handler is instead expected to flow only through the jsonrpc2
|
||||
// layer's explicit channels: the [Preempter] calling [Connection.Cancel] in
|
||||
// response to the peer's cancel notification, or the transport itself
|
||||
// failing (the read loop exits on EOF or a write fails) — both of which
|
||||
// cancel every in-flight incoming request in turn.
|
||||
type notDone struct{ ctx context.Context }
|
||||
|
||||
func (ic notDone) Value(key any) any {
|
||||
|
|
|
|||
122
mcp/client.go
122
mcp/client.go
|
|
@ -300,6 +300,27 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
|
|||
if hc, ok := cs.mcpConn.(clientConnection); ok {
|
||||
hc.sessionUpdated(cs.state)
|
||||
}
|
||||
subscribeParams := &SubscriptionsListenParams{}
|
||||
if c.opts.ToolListChangedHandler != nil {
|
||||
subscribeParams.Notifications.ToolsListChanged = true
|
||||
}
|
||||
if c.opts.PromptListChangedHandler != nil {
|
||||
subscribeParams.Notifications.PromptsListChanged = true
|
||||
}
|
||||
if c.opts.ResourceListChangedHandler != nil {
|
||||
subscribeParams.Notifications.ResourcesListChanged = true
|
||||
}
|
||||
if subscribeParams.Notifications.ToolsListChanged ||
|
||||
subscribeParams.Notifications.PromptsListChanged ||
|
||||
subscribeParams.Notifications.ResourcesListChanged {
|
||||
// ClientSession.Close cancels the listenCtx context to send notifications/cancelled.
|
||||
listenCtx, cancelListen := context.WithCancel(context.Background())
|
||||
cs.listenCancel = cancelListen
|
||||
if err := cs.subscriptionsListen(listenCtx, subscribeParams); err != nil {
|
||||
cancelListen()
|
||||
return nil, fmt.Errorf("opening subscriptions/listen: %w", err)
|
||||
}
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +435,7 @@ type ClientSession struct {
|
|||
conn *jsonrpc2.Connection
|
||||
client *Client
|
||||
keepaliveCancel context.CancelFunc
|
||||
listenCancel context.CancelFunc
|
||||
mcpConn Connection
|
||||
|
||||
// No mutex is (currently) required to guard the session state, because it is
|
||||
|
|
@ -430,6 +452,15 @@ type ClientSession struct {
|
|||
// Pending URL elicitations waiting for completion notifications.
|
||||
pendingElicitationsMu sync.Mutex
|
||||
pendingElicitations map[string]chan struct{}
|
||||
|
||||
// resourceSubsMu guards resourceSubs.
|
||||
resourceSubsMu sync.Mutex
|
||||
// resourceSubs maps a subscribed resource URI to the cancel func of the
|
||||
// goroutine running its dedicated subscriptions/listen stream. Populated
|
||||
// only under SEP-2575; the legacy protocol routes Subscribe and
|
||||
// Unsubscribe straight to the resources/subscribe and resources/unsubscribe
|
||||
// RPCs and leaves this map untouched.
|
||||
resourceSubs map[string]context.CancelFunc
|
||||
}
|
||||
|
||||
type clientSessionState struct {
|
||||
|
|
@ -496,6 +527,10 @@ func (cs *ClientSession) Close() error {
|
|||
if cs.keepaliveCancel != nil {
|
||||
cs.keepaliveCancel()
|
||||
}
|
||||
if cs.listenCancel != nil {
|
||||
cs.listenCancel()
|
||||
}
|
||||
cs.cancelAllResourceSubscriptions()
|
||||
err := cs.conn.Close()
|
||||
|
||||
if cs.onClose != nil && cs.calledOnClose.CompareAndSwap(false, true) {
|
||||
|
|
@ -1083,6 +1118,7 @@ var clientMethodInfos = map[string]methodInfo{
|
|||
notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification),
|
||||
notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification),
|
||||
notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK),
|
||||
notificationSubscriptionsAck: newClientMethodInfo(clientMethod((*Client).callSubscriptionsAckHandler), notification|missingParamsOK),
|
||||
}
|
||||
|
||||
func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo {
|
||||
|
|
@ -1279,15 +1315,89 @@ func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (
|
|||
// Subscribe sends a "resources/subscribe" request to the server, asking for
|
||||
// notifications when the specified resource changes.
|
||||
func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) error {
|
||||
_, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params)))
|
||||
if !cs.usesNewProtocol() {
|
||||
_, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params)))
|
||||
return err
|
||||
}
|
||||
if params == nil || params.URI == "" {
|
||||
return fmt.Errorf("Subscribe: missing URI")
|
||||
}
|
||||
uri := params.URI
|
||||
|
||||
var listenCtx context.Context
|
||||
cs.resourceSubsMu.Lock()
|
||||
if _, exists := cs.resourceSubs[uri]; !exists {
|
||||
var cancel context.CancelFunc
|
||||
listenCtx, cancel = context.WithCancel(context.Background())
|
||||
if cs.resourceSubs == nil {
|
||||
cs.resourceSubs = make(map[string]context.CancelFunc)
|
||||
}
|
||||
cs.resourceSubs[uri] = cancel
|
||||
}
|
||||
cs.resourceSubsMu.Unlock()
|
||||
if listenCtx == nil {
|
||||
// Already subscribed to this URI
|
||||
return nil
|
||||
}
|
||||
|
||||
return cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{
|
||||
Notifications: NotificationSubscriptions{
|
||||
ResourceSubscriptions: []string{uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Unsubscribe cancels a previous [ClientSession.Subscribe] for params.URI.
|
||||
//
|
||||
// Under the legacy protocol it sends a "resources/unsubscribe" request.
|
||||
//
|
||||
// Under SEP-2575 it cancels the background "subscriptions/listen" stream
|
||||
// opened by Subscribe for the URI. Unsubscribe is idempotent: calling it for
|
||||
// a URI that is not currently subscribed is a no-op.
|
||||
func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error {
|
||||
if !cs.usesNewProtocol() {
|
||||
_, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params)))
|
||||
return err
|
||||
}
|
||||
if params == nil || params.URI == "" {
|
||||
return fmt.Errorf("Unsubscribe: missing URI")
|
||||
}
|
||||
cs.resourceSubsMu.Lock()
|
||||
cancel, ok := cs.resourceSubs[params.URI]
|
||||
delete(cs.resourceSubs, params.URI)
|
||||
cs.resourceSubsMu.Unlock()
|
||||
if ok {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelAllResourceSubscriptions cancels every active SEP-2575 resource
|
||||
// subscription opened via Subscribe. The listen goroutines exit
|
||||
// asynchronously as their contexts unwind. Called from Close.
|
||||
func (cs *ClientSession) cancelAllResourceSubscriptions() {
|
||||
cs.resourceSubsMu.Lock()
|
||||
subs := cs.resourceSubs
|
||||
cs.resourceSubs = nil
|
||||
cs.resourceSubsMu.Unlock()
|
||||
for _, cancel := range subs {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionsListen opens a SEP-2575 "subscriptions/listen" stream.
|
||||
//
|
||||
// The server's first message on the stream is "notifications/subscriptions/acknowledged";
|
||||
// subsequent opted-in notifications (e.g. tools/list_changed) are delivered through the
|
||||
// usual handlers registered in [ClientOptions].
|
||||
func (cs *ClientSession) subscriptionsListen(ctx context.Context, params *SubscriptionsListenParams) error {
|
||||
params = injectRequestMeta(cs, params)
|
||||
_, err := handleSend[*emptyResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params)))
|
||||
return err
|
||||
}
|
||||
|
||||
// Unsubscribe sends a "resources/unsubscribe" request to the server, cancelling
|
||||
// a previous subscription.
|
||||
func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error {
|
||||
_, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params)))
|
||||
return err
|
||||
func (c *Client) callSubscriptionsAckHandler(context.Context, *ClientRequest[*SubscriptionsAcknowledgedParams]) (Result, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) {
|
||||
|
|
|
|||
790
mcp/mcp_test.go
790
mcp/mcp_test.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1931,6 +1931,49 @@ type ResourceUpdatedNotificationParams struct {
|
|||
func (x *ResourceUpdatedNotificationParams) isParams() {}
|
||||
func (x *ResourceUpdatedNotificationParams) isNil() bool { return x == nil }
|
||||
|
||||
// NotificationSubscriptions describes the set of server-to-client
|
||||
// notifications a client wishes to receive on a [SubscriptionsListenParams]
|
||||
// stream. Each field is an explicit opt-in: a server MUST NOT push
|
||||
// notifications of a type the client did not request.
|
||||
type NotificationSubscriptions struct {
|
||||
// ToolsListChanged opts in to "notifications/tools/list_changed".
|
||||
ToolsListChanged bool `json:"toolsListChanged,omitempty"`
|
||||
// PromptsListChanged opts in to "notifications/prompts/list_changed".
|
||||
PromptsListChanged bool `json:"promptsListChanged,omitempty"`
|
||||
// ResourcesListChanged opts in to "notifications/resources/list_changed".
|
||||
ResourcesListChanged bool `json:"resourcesListChanged,omitempty"`
|
||||
// ResourceSubscriptions enumerates the resource URIs for which the client
|
||||
// wants "notifications/resources/updated". Replaces the legacy
|
||||
// resources/subscribe RPC.
|
||||
ResourceSubscriptions []string `json:"resourceSubscriptions,omitempty"`
|
||||
}
|
||||
|
||||
// SubscriptionsListenParams are the parameters for the
|
||||
// "subscriptions/listen" RPC.
|
||||
type SubscriptionsListenParams struct {
|
||||
// Meta carries the per-request `_meta` triple.
|
||||
Meta `json:"_meta,omitempty"`
|
||||
// Notifications declares which notification types the client wants to
|
||||
// receive on this stream.
|
||||
Notifications NotificationSubscriptions `json:"notifications"`
|
||||
}
|
||||
|
||||
func (x *SubscriptionsListenParams) isParams() {}
|
||||
func (x *SubscriptionsListenParams) isNil() bool { return x == nil }
|
||||
|
||||
// SubscriptionsAcknowledgedParams are the parameters for the
|
||||
// "notifications/subscriptions/acknowledged" notification, which the server
|
||||
// MUST send as the first message on a subscriptions/listen stream. It carries
|
||||
// the subset of the requested [NotificationSubscriptions] that the server has
|
||||
// agreed to honor.
|
||||
type SubscriptionsAcknowledgedParams struct {
|
||||
Meta `json:"_meta,omitempty"`
|
||||
Notifications NotificationSubscriptions `json:"notifications"`
|
||||
}
|
||||
|
||||
func (x *SubscriptionsAcknowledgedParams) isParams() {}
|
||||
func (x *SubscriptionsAcknowledgedParams) isNil() bool { return x == nil }
|
||||
|
||||
// TODO(jba): add CompleteRequest and related types.
|
||||
|
||||
// A request from the server to elicit additional information from the user via the client.
|
||||
|
|
@ -2134,8 +2177,10 @@ const (
|
|||
notificationRootsListChanged = "notifications/roots/list_changed"
|
||||
methodSetLevel = "logging/setLevel"
|
||||
methodSubscribe = "resources/subscribe"
|
||||
methodSubscriptionsListen = "subscriptions/listen"
|
||||
notificationToolListChanged = "notifications/tools/list_changed"
|
||||
methodUnsubscribe = "resources/unsubscribe"
|
||||
notificationSubscriptionsAck = "notifications/subscriptions/acknowledged"
|
||||
)
|
||||
|
||||
// Per-request _meta field names for the >= 2026-07-28 protocol version.
|
||||
|
|
@ -2152,6 +2197,9 @@ const (
|
|||
MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities"
|
||||
// MetaKeyLogLevel identifies the desired log level for the request.
|
||||
MetaKeyLogLevel = "io.modelcontextprotocol/logLevel"
|
||||
// MetaKeySubscriptionID identifies the subscriptions/listen request that an
|
||||
// out-of-band notification belongs to.
|
||||
MetaKeySubscriptionID = "io.modelcontextprotocol/subscriptionId"
|
||||
)
|
||||
|
||||
// UnsupportedProtocolVersionData is the SEP-2575 payload carried in the
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type (
|
|||
ReadResourceRequest = ServerRequest[*ReadResourceParams]
|
||||
RootsListChangedRequest = ServerRequest[*RootsListChangedParams]
|
||||
SubscribeRequest = ServerRequest[*SubscribeParams]
|
||||
SubscriptionsListenRequest = ServerRequest[*SubscriptionsListenParams]
|
||||
UnsubscribeRequest = ServerRequest[*UnsubscribeParams]
|
||||
)
|
||||
|
||||
|
|
|
|||
237
mcp/server.go
237
mcp/server.go
|
|
@ -44,16 +44,19 @@ type Server struct {
|
|||
impl *Implementation
|
||||
opts ServerOptions
|
||||
|
||||
mu sync.Mutex
|
||||
prompts *featureSet[*serverPrompt]
|
||||
tools *featureSet[*serverTool]
|
||||
resources *featureSet[*serverResource]
|
||||
resourceTemplates *featureSet[*serverResourceTemplate]
|
||||
sessions []*ServerSession
|
||||
sendingMethodHandler_ MethodHandler
|
||||
receivingMethodHandler_ MethodHandler
|
||||
resourceSubscriptions map[string]map[*ServerSession]bool // uri -> session -> bool
|
||||
pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send
|
||||
mu sync.Mutex
|
||||
prompts *featureSet[*serverPrompt]
|
||||
tools *featureSet[*serverTool]
|
||||
resources *featureSet[*serverResource]
|
||||
resourceTemplates *featureSet[*serverResourceTemplate]
|
||||
sessions []*ServerSession
|
||||
sendingMethodHandler_ MethodHandler
|
||||
receivingMethodHandler_ MethodHandler
|
||||
toolChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "tools/changed"
|
||||
promptChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "prompts/changed"
|
||||
resourceChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "resources/changed"
|
||||
resourceSubscriptions map[string]map[*ServerSession]jsonrpc.ID // uri -> session -> requestID
|
||||
pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send
|
||||
}
|
||||
|
||||
// ServerOptions is used to configure behavior of the server.
|
||||
|
|
@ -195,16 +198,19 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
|
|||
}
|
||||
|
||||
s := &Server{
|
||||
impl: impl,
|
||||
opts: opts,
|
||||
prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }),
|
||||
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
|
||||
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
|
||||
resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }),
|
||||
sendingMethodHandler_: defaultSendingMethodHandler,
|
||||
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
|
||||
resourceSubscriptions: make(map[string]map[*ServerSession]bool),
|
||||
pendingNotifications: make(map[string]*time.Timer),
|
||||
impl: impl,
|
||||
opts: opts,
|
||||
prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }),
|
||||
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
|
||||
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
|
||||
resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }),
|
||||
sendingMethodHandler_: defaultSendingMethodHandler,
|
||||
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
|
||||
toolChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID),
|
||||
promptChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID),
|
||||
resourceChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID),
|
||||
resourceSubscriptions: make(map[string]map[*ServerSession]jsonrpc.ID),
|
||||
pendingNotifications: make(map[string]*time.Timer),
|
||||
}
|
||||
s.AddReceivingMiddleware(serverMultiRoundTripMiddleware())
|
||||
return s
|
||||
|
|
@ -647,12 +653,13 @@ func (s *Server) complete(ctx context.Context, req *CompleteRequest) (*CompleteR
|
|||
return s.opts.CompletionHandler(ctx, req)
|
||||
}
|
||||
|
||||
// Map from notification name to its corresponding params. The params have no fields,
|
||||
// so a single struct can be reused.
|
||||
var changeNotificationParams = map[string]Params{
|
||||
notificationToolListChanged: &ToolListChangedParams{},
|
||||
notificationPromptListChanged: &PromptListChangedParams{},
|
||||
notificationResourceListChanged: &ResourceListChangedParams{},
|
||||
// Map from notification name to a function creating its corresponding Params.
|
||||
// We need to create a fresh one each time to add the jsonrpc ID. See
|
||||
// [injectMetaSubscriptionID].
|
||||
var changeNotificationParams = map[string]func() Params{
|
||||
notificationToolListChanged: func() Params { return &ToolListChangedParams{} },
|
||||
notificationPromptListChanged: func() Params { return &PromptListChangedParams{} },
|
||||
notificationResourceListChanged: func() Params { return &ResourceListChangedParams{} },
|
||||
}
|
||||
|
||||
// How long to wait before sending a change notification.
|
||||
|
|
@ -686,12 +693,62 @@ func (s *Server) changeAndNotify(notification string, change func() bool) {
|
|||
|
||||
// notifySessions sends the notification n to all existing sessions.
|
||||
// It is called asynchronously by changeAndNotify.
|
||||
//
|
||||
// Legacy (pre-SEP-2575) sessions receive the notification on the shared
|
||||
// session channel. Sessions speaking the new protocol receive it only if they
|
||||
// have an active subscriptions/listen stream that opted in to this
|
||||
// notification type.
|
||||
func (s *Server) notifySessions(n string) {
|
||||
s.mu.Lock()
|
||||
sessions := slices.Clone(s.sessions)
|
||||
s.pendingNotifications[n] = nil
|
||||
// Legacy (pre-SEP-2575) sessions receive list-changed notifications on the
|
||||
// shared session channel without opt-in; collect them while we hold the lock.
|
||||
var legacySessions []*ServerSession
|
||||
for _, sess := range s.sessions {
|
||||
if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 {
|
||||
legacySessions = append(legacySessions, sess)
|
||||
}
|
||||
}
|
||||
var subscribers map[*ServerSession]jsonrpc.ID
|
||||
switch n {
|
||||
case notificationToolListChanged:
|
||||
subscribers = maps.Clone(s.toolChangeSubscriptions)
|
||||
case notificationPromptListChanged:
|
||||
subscribers = maps.Clone(s.promptChangeSubscriptions)
|
||||
case notificationResourceListChanged:
|
||||
subscribers = maps.Clone(s.resourceChangeSubscriptions)
|
||||
}
|
||||
s.mu.Unlock() // Don't hold the lock during notification: it causes deadlock.
|
||||
notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger)
|
||||
|
||||
notifySessions(legacySessions, n, changeNotificationParams[n](), s.opts.Logger)
|
||||
|
||||
// Sessions receive the notification only if they opened a
|
||||
// subscriptions/listen stream that opted in to this notification type.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for sess, reqID := range subscribers {
|
||||
params := changeNotificationParams[n]()
|
||||
injectMetaSubscriptionID(params, reqID)
|
||||
req := newRequest(sess, params)
|
||||
if err := handleNotify(ctx, n, req); err != nil {
|
||||
s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", n, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// injectMetaSubscriptionID stamps the listen request's JSON-RPC ID into the
|
||||
// params' _meta under [MetaKeySubscriptionID], so the receiving client can
|
||||
// correlate the notification with the [subscriptions/listen] stream it
|
||||
// originated.
|
||||
//
|
||||
// [subscriptions/listen]: https://modelcontextprotocol.io/seps/2575-stateless-mcp#multiple-concurrent-subscriptions
|
||||
func injectMetaSubscriptionID(params Params, reqID jsonrpc.ID) {
|
||||
m := params.GetMeta()
|
||||
if m == nil {
|
||||
m = map[string]any{}
|
||||
}
|
||||
m[MetaKeySubscriptionID] = reqID.Raw()
|
||||
params.SetMeta(m)
|
||||
}
|
||||
|
||||
// shouldSendListChangedNotification checks if the server's capabilities allow
|
||||
|
|
@ -1006,12 +1063,38 @@ func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNot
|
|||
subscribedSessions := s.resourceSubscriptions[params.URI]
|
||||
sessions := slices.Collect(maps.Keys(subscribedSessions))
|
||||
s.mu.Unlock()
|
||||
notifySessions(sessions, notificationResourceUpdated, params, s.opts.Logger)
|
||||
// Only add legacy sessions for the notification, new ones use the new notification mechanism.
|
||||
var legacySessions []*ServerSession
|
||||
var newSessions []*ServerSession
|
||||
for _, sess := range sessions {
|
||||
if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 {
|
||||
legacySessions = append(legacySessions, sess)
|
||||
} else {
|
||||
newSessions = append(newSessions, sess)
|
||||
}
|
||||
}
|
||||
notifySessions(legacySessions, notificationResourceUpdated, params, s.opts.Logger)
|
||||
s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions))
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
for _, sess := range newSessions {
|
||||
reqID := subscribedSessions[sess]
|
||||
p := *params
|
||||
injectMetaSubscriptionID(&p, reqID)
|
||||
req := newRequest(sess, &p)
|
||||
if err := handleNotify(ctx, notificationResourceUpdated, req); err != nil {
|
||||
s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", notificationResourceUpdated, err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyResult, error) {
|
||||
requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID)
|
||||
if !ok || !requestID.IsValid() {
|
||||
return nil, fmt.Errorf("%w: subscribe requires a request ID", jsonrpc2.ErrInvalidRequest)
|
||||
}
|
||||
if s.opts.SubscribeHandler == nil {
|
||||
return nil, fmt.Errorf("%w: server does not support resource subscriptions", jsonrpc2.ErrMethodNotFound)
|
||||
}
|
||||
|
|
@ -1022,10 +1105,10 @@ func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyRe
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.resourceSubscriptions[req.Params.URI] == nil {
|
||||
s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]bool)
|
||||
s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]jsonrpc.ID)
|
||||
}
|
||||
s.resourceSubscriptions[req.Params.URI][req.Session] = true
|
||||
s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID())
|
||||
s.resourceSubscriptions[req.Params.URI][req.Session] = requestID
|
||||
s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID(), "request_id", requestID)
|
||||
|
||||
return &emptyResult{}, nil
|
||||
}
|
||||
|
|
@ -1052,6 +1135,82 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp
|
|||
return &emptyResult{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*emptyResult, error) {
|
||||
requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID)
|
||||
if !ok || !requestID.IsValid() {
|
||||
return nil, fmt.Errorf("%w: subscriptions/listen requires a request ID", jsonrpc2.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
allowed := s.allowedSubscriptions(req.Params.Notifications)
|
||||
s.mu.Lock()
|
||||
if allowed.ToolsListChanged {
|
||||
s.toolChangeSubscriptions[req.Session] = requestID
|
||||
}
|
||||
if allowed.PromptsListChanged {
|
||||
s.promptChangeSubscriptions[req.Session] = requestID
|
||||
}
|
||||
if allowed.ResourcesListChanged {
|
||||
s.resourceChangeSubscriptions[req.Session] = requestID
|
||||
}
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.toolChangeSubscriptions, req.Session)
|
||||
delete(s.promptChangeSubscriptions, req.Session)
|
||||
delete(s.resourceChangeSubscriptions, req.Session)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
for _, uri := range allowed.ResourceSubscriptions {
|
||||
_, err := s.subscribe(ctx, &SubscribeRequest{
|
||||
Session: req.Session,
|
||||
Params: &SubscribeParams{
|
||||
URI: uri,
|
||||
Meta: req.Params.GetMeta(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.unsubscribe(ctx, &UnsubscribeRequest{
|
||||
Session: req.Session,
|
||||
Params: &UnsubscribeParams{
|
||||
URI: uri,
|
||||
Meta: req.Params.GetMeta(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ackParams := &SubscriptionsAcknowledgedParams{
|
||||
Notifications: allowed,
|
||||
Meta: Meta{MetaKeySubscriptionID: requestID.Raw()},
|
||||
}
|
||||
if err := req.Session.notifySubscriptionAcked(ctx, ackParams); err != nil {
|
||||
return nil, fmt.Errorf("sending subscriptions/acknowledged: %w", err)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
return &emptyResult{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) allowedSubscriptions(want NotificationSubscriptions) NotificationSubscriptions {
|
||||
caps := s.capabilities()
|
||||
agreed := NotificationSubscriptions{}
|
||||
if want.ToolsListChanged && caps.Tools != nil && caps.Tools.ListChanged {
|
||||
agreed.ToolsListChanged = true
|
||||
}
|
||||
if want.PromptsListChanged && caps.Prompts != nil && caps.Prompts.ListChanged {
|
||||
agreed.PromptsListChanged = true
|
||||
}
|
||||
if want.ResourcesListChanged && caps.Resources != nil && caps.Resources.ListChanged {
|
||||
agreed.ResourcesListChanged = true
|
||||
}
|
||||
if len(want.ResourceSubscriptions) > 0 && caps.Resources != nil && caps.Resources.Subscribe {
|
||||
agreed.ResourceSubscriptions = slices.Clone(want.ResourceSubscriptions)
|
||||
}
|
||||
return agreed
|
||||
}
|
||||
|
||||
// Run runs the server over the given transport, which must be persistent.
|
||||
//
|
||||
// Run blocks until the client terminates the connection or the provided
|
||||
|
|
@ -1121,6 +1280,10 @@ func (s *Server) disconnect(cc *ServerSession) {
|
|||
for _, subscribedSessions := range s.resourceSubscriptions {
|
||||
delete(subscribedSessions, cc)
|
||||
}
|
||||
delete(s.toolChangeSubscriptions, cc)
|
||||
delete(s.promptChangeSubscriptions, cc)
|
||||
delete(s.resourceChangeSubscriptions, cc)
|
||||
|
||||
s.opts.Logger.Info("server session disconnected", "session_id", cc.ID())
|
||||
}
|
||||
|
||||
|
|
@ -1221,6 +1384,13 @@ func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNot
|
|||
return handleNotify(ctx, notificationProgress, newServerRequest(ss, orZero[Params](params)))
|
||||
}
|
||||
|
||||
// notifySubscriptionAcked sends a "notifications/subscriptions/acknowledged"
|
||||
// notification on the listen stream represented by this session, indicating
|
||||
// the subscription filter the server accepted (SEP-2575).
|
||||
func (ss *ServerSession) notifySubscriptionAcked(ctx context.Context, params *SubscriptionsAcknowledgedParams) error {
|
||||
return handleNotify(ctx, notificationSubscriptionsAck, newServerRequest(ss, orZero[Params](params)))
|
||||
}
|
||||
|
||||
func newServerRequest[P Params](ss *ServerSession, params P) *ServerRequest[P] {
|
||||
return &ServerRequest[P]{Session: ss, Params: params}
|
||||
}
|
||||
|
|
@ -1522,6 +1692,7 @@ var serverMethodInfos = map[string]methodInfo{
|
|||
methodReadResource: newServerMethodInfo(serverMethod((*Server).readResource), 0),
|
||||
methodSetLevel: newServerMethodInfo(serverSessionMethod((*ServerSession).setLevel), 0),
|
||||
methodSubscribe: newServerMethodInfo(serverMethod((*Server).subscribe), 0),
|
||||
methodSubscriptionsListen: newServerMethodInfo(serverMethod((*Server).subscriptionsListen), 0),
|
||||
methodUnsubscribe: newServerMethodInfo(serverMethod((*Server).unsubscribe), 0),
|
||||
notificationCancelled: newServerMethodInfo(serverSessionMethod((*ServerSession).cancel), notification|missingParamsOK),
|
||||
notificationInitialized: newServerMethodInfo(serverSessionMethod((*ServerSession).initialized), notification|missingParamsOK),
|
||||
|
|
@ -1598,7 +1769,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
|
|||
}
|
||||
|
||||
switch req.Method {
|
||||
case methodInitialize, methodPing, notificationInitialized, methodSetLevel:
|
||||
case methodInitialize, methodPing, notificationInitialized, methodSetLevel, methodSubscribe, methodUnsubscribe:
|
||||
if validatedMeta.usesNewProtocol {
|
||||
ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method)
|
||||
return nil, &jsonrpc.Error{
|
||||
|
|
|
|||
|
|
@ -123,8 +123,12 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request
|
|||
// Create the result to unmarshal into.
|
||||
// The concrete type of the result is the return type of the receiving function.
|
||||
res := info.newResult()
|
||||
if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil {
|
||||
return nil, err
|
||||
if method == methodSubscriptionsListen {
|
||||
callSubscriptionsListen(ctx, req.GetSession().getConn(), method, params)
|
||||
} else {
|
||||
if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -366,14 +366,14 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
|
|||
return
|
||||
}
|
||||
|
||||
connectOpts, usesNewProtocol, err := h.ephemeralConnectOpts(req)
|
||||
info, err := h.ephemeralConnectOpts(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
if legacySessions && !usesNewProtocol {
|
||||
if legacySessions && !info.usesNewProtocol {
|
||||
sessionID = req.Header.Get(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
sessionID = server.opts.GetSessionID()
|
||||
|
|
@ -381,14 +381,15 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
|
|||
}
|
||||
|
||||
transport := &StreamableServerTransport{
|
||||
SessionID: sessionID,
|
||||
Stateless: true,
|
||||
EventStore: h.opts.EventStore,
|
||||
jsonResponse: h.opts.JSONResponse,
|
||||
logger: h.opts.Logger,
|
||||
SessionID: sessionID,
|
||||
Stateless: true,
|
||||
EventStore: h.opts.EventStore,
|
||||
jsonResponse: h.opts.JSONResponse,
|
||||
logger: h.opts.Logger,
|
||||
shouldPropagateCancellation: info.isSubscriptionsListen && info.usesNewProtocol,
|
||||
}
|
||||
|
||||
session, err := connectStreamable(req.Context(), server, transport, connectOpts)
|
||||
session, err := connectStreamable(req.Context(), server, transport, info.opts)
|
||||
if err != nil {
|
||||
h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err))
|
||||
http.Error(w, "failed connection", http.StatusInternalServerError)
|
||||
|
|
@ -412,27 +413,29 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ephemeralConnectOpts peeks at the request body to determine whether it
|
||||
// contains an initialize or initialized message or whether the protocol version
|
||||
// header indicates a protocol version >= 2026-07-28 (SEP-2575).
|
||||
type ephemeralConnectInfo struct {
|
||||
opts *ServerSessionOptions
|
||||
usesNewProtocol bool
|
||||
isSubscriptionsListen bool
|
||||
}
|
||||
|
||||
// ephemeralConnectOpts peeks at the request body to determine connection
|
||||
// parameters and whether protocol version >= 2026-06-30 (SEP-2575).
|
||||
//
|
||||
// For old-protocol requests, default session state is synthesized so that
|
||||
// the session's init gate doesn't reject the request.
|
||||
//
|
||||
// It is used for both stateless servers and stateful servers with no session ID.
|
||||
//
|
||||
// The returned usesNewProtocol bool reports whether the protocol version
|
||||
// header indicates a protocol version >= 2026-07-28 (SEP-2575).
|
||||
func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) {
|
||||
func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*ephemeralConnectInfo, error) {
|
||||
protocolVersion := protocolVersionFromContext(req.Context())
|
||||
if protocolVersion == "" {
|
||||
protocolVersion = protocolVersion20250326
|
||||
}
|
||||
|
||||
var hasInitialize, hasInitialized bool
|
||||
var hasInitialize, hasInitialized, usesNewProtocol, isSubscriptionsListen bool
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to read body")
|
||||
return nil, fmt.Errorf("failed to read body")
|
||||
}
|
||||
req.Body.Close()
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
|
@ -445,6 +448,8 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S
|
|||
hasInitialize = true
|
||||
case notificationInitialized:
|
||||
hasInitialized = true
|
||||
case methodSubscriptionsListen:
|
||||
isSubscriptionsListen = true
|
||||
}
|
||||
if protocolVersion >= protocolVersion20260728 {
|
||||
usesNewProtocol = true
|
||||
|
|
@ -467,9 +472,13 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S
|
|||
if !usesNewProtocol {
|
||||
state.LogLevel = "info"
|
||||
}
|
||||
return &ServerSessionOptions{
|
||||
State: state,
|
||||
}, usesNewProtocol, nil
|
||||
return &ephemeralConnectInfo{
|
||||
opts: &ServerSessionOptions{
|
||||
State: state,
|
||||
},
|
||||
usesNewProtocol: usesNewProtocol,
|
||||
isSubscriptionsListen: isSubscriptionsListen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func connectStreamable(ctx context.Context, server *Server, transport *StreamableServerTransport, opts *ServerSessionOptions) (*ServerSession, error) {
|
||||
|
|
@ -614,12 +623,12 @@ func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *ht
|
|||
// that arrives before a session exists (e.g. initialize or ping) on a
|
||||
// server configured this way.
|
||||
if sessionID == "" {
|
||||
connectOpts, _, err := h.ephemeralConnectOpts(req)
|
||||
info, err := h.ephemeralConnectOpts(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
session, err := connectStreamable(req.Context(), server, transport, connectOpts)
|
||||
session, err := connectStreamable(req.Context(), server, transport, info.opts)
|
||||
if err != nil {
|
||||
h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err))
|
||||
http.Error(w, "failed connection", http.StatusInternalServerError)
|
||||
|
|
@ -775,6 +784,10 @@ type StreamableServerTransport struct {
|
|||
// to write their own streamable HTTP handler.
|
||||
logger *slog.Logger
|
||||
|
||||
// shouldPropagateCancellation is forwarded to the underlying
|
||||
// [streamableServerConn]. See its docstring.
|
||||
shouldPropagateCancellation bool
|
||||
|
||||
// connection is non-nil if and only if the transport has been connected.
|
||||
connection *streamableServerConn
|
||||
}
|
||||
|
|
@ -785,15 +798,16 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er
|
|||
return nil, fmt.Errorf("transport already connected")
|
||||
}
|
||||
t.connection = &streamableServerConn{
|
||||
sessionID: t.SessionID,
|
||||
stateless: t.Stateless,
|
||||
eventStore: t.EventStore,
|
||||
jsonResponse: t.jsonResponse,
|
||||
logger: ensureLogger(t.logger), // see #556: must be non-nil
|
||||
incoming: make(chan jsonrpc.Message, 10),
|
||||
done: make(chan struct{}),
|
||||
streams: make(map[string]*stream),
|
||||
requestStreams: make(map[jsonrpc.ID]string),
|
||||
sessionID: t.SessionID,
|
||||
stateless: t.Stateless,
|
||||
eventStore: t.EventStore,
|
||||
jsonResponse: t.jsonResponse,
|
||||
logger: ensureLogger(t.logger), // see #556: must be non-nil
|
||||
shouldPropagateCancellation: t.shouldPropagateCancellation,
|
||||
incoming: make(chan jsonrpc.Message, 10),
|
||||
done: make(chan struct{}),
|
||||
streams: make(map[string]*stream),
|
||||
requestStreams: make(map[jsonrpc.ID]string),
|
||||
}
|
||||
// Stream 0 corresponds to the standalone SSE stream.
|
||||
//
|
||||
|
|
@ -823,6 +837,13 @@ type streamableServerConn struct {
|
|||
jsonResponse bool
|
||||
eventStore EventStore
|
||||
|
||||
// shouldPropagateCancellation is true when the underlying HTTP request's
|
||||
// lifetime IS the connection's cancellation signal (e.g., a stateless
|
||||
// POST that owns a long-lived subscriptions/listen stream). It is read
|
||||
// by the [cancellationPropagator] interface so the jsonrpc2 layer wires
|
||||
// handler contexts to observe the carrier's cancellation.
|
||||
shouldPropagateCancellation bool
|
||||
|
||||
logger *slog.Logger
|
||||
|
||||
toolLookup func(name string) (*serverTool, bool)
|
||||
|
|
@ -860,6 +881,15 @@ func (c *streamableServerConn) SessionID() string {
|
|||
return c.sessionID
|
||||
}
|
||||
|
||||
// propagateCancellation implements [cancellationPropagator]. It returns true
|
||||
// when this connection is bound to a single HTTP request whose lifetime
|
||||
// should drive request-handler cancellation — for example, a stateless POST
|
||||
// carrying a long-lived subscriptions/listen stream that must unwind when
|
||||
// the client TCP-disconnects.
|
||||
func (c *streamableServerConn) propagateCancellation() bool {
|
||||
return c.shouldPropagateCancellation
|
||||
}
|
||||
|
||||
// A stream is a single logical stream of SSE events within a server session.
|
||||
// A stream begins with a client request, or with a client GET that has
|
||||
// no Last-Event-ID header.
|
||||
|
|
@ -914,6 +944,12 @@ type stream struct {
|
|||
// the spec and earlier there was a concept of batching, in which POST
|
||||
// payloads could hold multiple requests or responses.
|
||||
requests map[jsonrpc.ID]struct{}
|
||||
|
||||
// isListen reports whether this stream was opened by a
|
||||
// subscriptions/listen request. Listen streams are always SSE, live for
|
||||
// the duration of the subscription, and act as the target for
|
||||
// out-of-band notifications routed through this connection.
|
||||
isListen bool
|
||||
}
|
||||
|
||||
// close sends a 'close' event to the client (if protocolVersion >= 2025-11-25
|
||||
|
|
@ -1372,6 +1408,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
|||
calls := make(map[jsonrpc.ID]struct{})
|
||||
tokenInfo := auth.TokenInfoFromContext(req.Context())
|
||||
isInitialize := false
|
||||
isSubscriptionsListen := false
|
||||
var initializeProtocolVersion string
|
||||
for _, msg := range incoming {
|
||||
if jreq, ok := msg.(*jsonrpc.Request); ok {
|
||||
|
|
@ -1397,6 +1434,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
|||
initializeProtocolVersion = params.ProtocolVersion
|
||||
}
|
||||
}
|
||||
if jreq.Method == methodSubscriptionsListen {
|
||||
isSubscriptionsListen = true
|
||||
}
|
||||
// SEP-2575: requests carrying `_meta.protocolVersion` require the
|
||||
// Mcp-Protocol-Version HTTP header to be present and to match the
|
||||
// per-request `_meta.protocolVersion` value.
|
||||
|
|
@ -1542,14 +1582,22 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
|||
http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stream.isListen = isSubscriptionsListen
|
||||
|
||||
// subscriptions/listen is inherently a long-lived SSE endpoint (SEP-2575):
|
||||
// it has no synchronous result, the response stream stays open until the
|
||||
// client cancels, and the server pushes notifications on it as they occur.
|
||||
// Force SSE mode (bypassing JSONResponse) so the buffered application/json
|
||||
// path doesn't deadlock waiting for a completion that won't come.
|
||||
useSSE := !c.jsonResponse || isSubscriptionsListen
|
||||
|
||||
// Set response headers. Accept was checked in [StreamableHTTPHandler].
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
if c.jsonResponse {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
} else {
|
||||
if useSSE {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.sessionID != "" && isInitialize {
|
||||
w.Header().Set(sessionIDHeader, c.sessionID)
|
||||
|
|
@ -1584,7 +1632,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
|||
// remaining requests here, since the client will never get the results.
|
||||
defer stream.release()
|
||||
|
||||
if c.jsonResponse {
|
||||
if !useSSE {
|
||||
// JSON mode: collect messages in pendingJSONMessages until done.
|
||||
// Set pendingJSONMessages to a non-nil value to signal that this is an
|
||||
// application/json stream.
|
||||
|
|
@ -1724,7 +1772,18 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e
|
|||
s = c.streams[streamID]
|
||||
}
|
||||
} else {
|
||||
s = c.streams[""] // standalone SSE stream
|
||||
// In stateless mode there will always be only one stream per connection.
|
||||
// If that stream was open to listen for subscription notifications,
|
||||
// automatically select as the one to write the notification to.
|
||||
for _, stream := range c.streams {
|
||||
if stream.isListen {
|
||||
s = stream
|
||||
break
|
||||
}
|
||||
}
|
||||
if s == nil {
|
||||
s = c.streams[""] // standalone SSE stream
|
||||
}
|
||||
}
|
||||
if responseTo.IsValid() {
|
||||
// Once we've responded to a request, disallow related messages by removing
|
||||
|
|
|
|||
|
|
@ -3319,20 +3319,20 @@ func TestEphemeralConnectOpts(t *testing.T) {
|
|||
}
|
||||
req.Header.Set(protocolVersionHeader, pver)
|
||||
req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver))
|
||||
opts, usesNew, err := h.ephemeralConnectOpts(req)
|
||||
info, err := h.ephemeralConnectOpts(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usesNew != tt.wantUsesNew {
|
||||
t.Errorf("usesNewProtocol = %v, want %v", usesNew, tt.wantUsesNew)
|
||||
if info.usesNewProtocol != tt.wantUsesNew {
|
||||
t.Errorf("usesNewProtocol = %v, want %v", info.usesNewProtocol, tt.wantUsesNew)
|
||||
}
|
||||
if got := opts.State.InitializeParams != nil; got != tt.wantInitializeParams {
|
||||
if got := info.opts.State.InitializeParams != nil; got != tt.wantInitializeParams {
|
||||
t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)",
|
||||
got, tt.wantInitializeParams, opts.State.InitializeParams)
|
||||
got, tt.wantInitializeParams, info.opts.State.InitializeParams)
|
||||
}
|
||||
if got := opts.State.InitializedParams != nil; got != tt.wantInitializedParams {
|
||||
if got := info.opts.State.InitializedParams != nil; got != tt.wantInitializedParams {
|
||||
t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)",
|
||||
got, tt.wantInitializedParams, opts.State.InitializedParams)
|
||||
got, tt.wantInitializedParams, info.opts.State.InitializedParams)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,13 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H,
|
|||
preempter.conn = conn
|
||||
return jsonrpc2.HandlerFunc(h.handle)
|
||||
}
|
||||
// Transports may opt in to propagating cancellation of ctx into request
|
||||
// handler contexts when their own lifecycle IS the cancellation signal
|
||||
// (e.g., a connection bound to a single HTTP request).
|
||||
var propagateCancellation bool
|
||||
if cp, ok := mcpConn.(cancellationPropagator); ok {
|
||||
propagateCancellation = cp.propagateCancellation()
|
||||
}
|
||||
_ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{
|
||||
Reader: reader,
|
||||
Writer: writer,
|
||||
|
|
@ -203,11 +210,22 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H,
|
|||
OnInternalError: func(err error) {
|
||||
logger.Error("jsonrpc2 internal error", "error", err)
|
||||
},
|
||||
PropagateCancellation: propagateCancellation,
|
||||
})
|
||||
assert(preempter.conn != nil, "unbound preempter")
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// cancellationPropagator is an optional interface implemented by a
|
||||
// [Connection] whose own lifecycle should propagate cancellation into request
|
||||
// handler contexts. The default jsonrpc2 behavior is to suppress propagation;
|
||||
// transports that bind a connection to a single short-lived carrier (such as
|
||||
// a one-shot HTTP request) should return true here so that handlers unwind
|
||||
// when the carrier observes the peer going away.
|
||||
type cancellationPropagator interface {
|
||||
propagateCancellation() bool
|
||||
}
|
||||
|
||||
// A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP
|
||||
// cancelled notifications.
|
||||
type canceller struct {
|
||||
|
|
@ -230,6 +248,24 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a
|
|||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
|
||||
// callSubscriptionsListen issues a "subscriptions/listen" call (SEP-2575)
|
||||
// without awaiting its JSON-RPC response. The call's logical lifetime is the
|
||||
// stream of notifications that follow on the same channel — the empty
|
||||
// response, if ever delivered, only marks subscription teardown — so the
|
||||
// caller has nothing useful to block on.
|
||||
//
|
||||
// Cancellation is driven by ctx: when it is cancelled, a background goroutine
|
||||
// sends a "notifications/cancelled" notification referencing the listen's
|
||||
// request ID and retires the call from the connection's outgoing-calls map.
|
||||
func callSubscriptionsListen(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params) {
|
||||
call := conn.Call(ctx, method, params)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = cancelCall(ctx, conn, call)
|
||||
}()
|
||||
}
|
||||
|
||||
// call executes and awaits a jsonrpc2 call on the given connection,
|
||||
// translating errors into the mcp domain.
|
||||
func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error {
|
||||
|
|
@ -240,24 +276,7 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params
|
|||
case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing):
|
||||
return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err)
|
||||
case ctx.Err() != nil:
|
||||
notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout)
|
||||
defer cancelNotify()
|
||||
err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{
|
||||
Reason: ctx.Err().Error(),
|
||||
RequestID: call.ID().Raw(),
|
||||
})
|
||||
// By default, the jsonrpc2 library waits for graceful shutdown when the
|
||||
// connection is closed, meaning it expects all outgoing and incoming
|
||||
// requests to complete. However, for MCP this expectation is unrealistic,
|
||||
// and can lead to hanging shutdown. For example, if a streamable client is
|
||||
// killed, the server will not be able to detect this event, except via
|
||||
// keepalive pings (if they are configured), and so outgoing calls may hang
|
||||
// indefinitely.
|
||||
//
|
||||
// Therefore, we choose to eagerly retire calls, removing them from the
|
||||
// outgoingCalls map, when the caller context is cancelled: if the caller
|
||||
// will never receive the response, there's no need to track it.
|
||||
conn.Retire(call, ctx.Err())
|
||||
err := cancelCall(ctx, conn, call)
|
||||
return errors.Join(ctx.Err(), err)
|
||||
case err != nil:
|
||||
return fmt.Errorf("calling %q: %w", method, err)
|
||||
|
|
@ -265,6 +284,30 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params
|
|||
return nil
|
||||
}
|
||||
|
||||
// cancelCall sends a "notifications/cancelled" notification for call and eagerly
|
||||
// retires it from conn.
|
||||
//
|
||||
// By default, the jsonrpc2 library waits for graceful shutdown when the
|
||||
// connection is closed, meaning it expects all outgoing and incoming requests
|
||||
// to complete. However, for MCP this expectation is unrealistic, and can lead
|
||||
// to hanging shutdown. For example, if a streamable client is killed, the
|
||||
// server will not be able to detect this event, except via keepalive pings (if
|
||||
// they are configured), and so outgoing calls may hang indefinitely.
|
||||
//
|
||||
// Therefore, we choose to eagerly retire calls, removing them from the
|
||||
// outgoingCalls map, when the caller context is cancelled: if the caller will
|
||||
// never receive the response, there's no need to track it.
|
||||
func cancelCall(ctx context.Context, conn *jsonrpc2.Connection, call *jsonrpc2.AsyncCall) error {
|
||||
notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout)
|
||||
defer cancelNotify()
|
||||
err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{
|
||||
Reason: ctx.Err().Error(),
|
||||
RequestID: call.ID().Raw(),
|
||||
})
|
||||
conn.Retire(call, ctx.Err())
|
||||
return err
|
||||
}
|
||||
|
||||
// A LoggingTransport is a [Transport] that delegates to another transport,
|
||||
// writing RPC logs to an io.Writer.
|
||||
type LoggingTransport struct {
|
||||
|
|
|
|||
Loading…
Reference in a new issue