modelcontextprotocol / modelcontextprotocol/go-sdk
mcp: a server cannot send the notifications/cancelled that 2026-07-28 requires when it tears down a subscriptions/listen stream
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5.1k
- Forks
- 543
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 37
Description
Protocol version 2026-07-28 requires a server to send notifications/cancelled referencing a subscriptions/listen request ID when it tears that subscription stream down. A server built on this SDK cannot: no exported API sends that notification, and the request ID it would have to reference is not reachable from application code either.
Filing this as a proposal under CONTRIBUTING, since the smallest fix I can see needs one new exported method. I found it while auditing my own server against the revision it advertises, and of everything that audit turned up it is the one gap that is both a MUST and impossible to close in my own code.
What the specification requires
The Cancellation page, second paragraph:
A server MUST send
notifications/cancelledreferencing asubscriptions/listenrequest ID when it tears down that subscription stream (see Subscriptions). Servers MUST NOT sendnotifications/cancelledfor any other purpose.
Behavior requirement 2 on the same page says it again: "Server-sent cancellation notifications MUST reference a subscriptions/listen request, to terminate that subscription stream".
The Subscriptions page carries the other half of the teardown, and that one is a SHOULD: when the server ends a subscription on its own initiative "it SHOULD respond to the original subscriptions/listen request with a completion result before closing the stream".
So a server that tears a stream down owes its client two messages, one required and one recommended. The SDK sends the recommended one and offers no way to send the required one.
What the SDK does instead
(*Server).subscriptionsListen (mcp/server.go:1256) is the whole server-side lifetime of a stream: it acknowledges the subscription (mcp/server.go:1306), parks on ctx.Done (mcp/server.go:1316) and returns a SubscriptionsListenResult when that context ends (mcp/server.go:1318). That is the SHOULD, and there is no cancelled notification anywhere on the path.
The only two places in the SDK that send notifications/cancelled are call (mcp/transport.go:294, the send at mcp/transport.go:318) and cancelCall (mcp/transport.go:343, the send at mcp/transport.go:346). Both read the ID from call.ID(), which is an outgoing call of the peer doing the sending, so both are a peer cancelling requests it issued itself. A server reaches them only for its own outgoing requests, never for a subscriptions/listen it is serving.
ServerSession.Close (mcp/server.go:2136) is the SDK's own teardown point and shows the gap most plainly: it cancels every in-flight listen by ID (mcp/server.go:2155, over the IDs in ServerSession.listenIDs at mcp/server.go:1566, which exist because of #1160) and sends nothing for any of them.
No send path is exported either. ServerSession has NotifyProgress (mcp/server.go:1520), NotifyElicitationComplete (mcp/server.go:1796) and Log (mcp/server.go:1817), and no counterpart for cancellation. handleNotify (mcp/shared.go:179) and the method tables are unexported, and notifications/cancelled appears in them only as a receiving handler, on both sides (mcp/client.go:1167, mcp/server.go:1887).
The request ID is not reachable from application code either. ServerRequest[P] carries Session, Params and Extra, and no ID (mcp/shared.go:623). The listen's JSON-RPC ID is in the handler's context under the unexported idContextKey (mcp/streamable.go:1188, installed at mcp/server.go:2010), with your own note listing an accessor for it as one of three deferred options (mcp/streamable.go:1183). What is exported is the value rather than the key: the SDK stamps the ID into the acknowledged notification's _meta under MetaKeySubscriptionID (mcp/server.go:1306 and injectMetaSubscriptionID at mcp/server.go:832; the constant is mcp/protocol.go:2376), so a sending middleware can read it back.
Reproduction
This is the shape my server has to use: the only hook application code has into the lifetime of a listen stream is a receiving middleware, and the only way to end one stream is to cancel the handler's context, which makes the SDK write the completion result.
package main
import (
"bytes"
"context"
"fmt"
"log"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
ctx := context.Background()
var (
mu sync.Mutex
endStream context.CancelFunc
)
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "1"}, &mcp.ServerOptions{
SubscribeHandler: func(context.Context, *mcp.SubscribeRequest) error { return nil },
UnsubscribeHandler: func(context.Context, *mcp.UnsubscribeRequest) error { return nil },
})
server.AddResource(&mcp.Resource{URI: "test:///r", Name: "r"},
func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: "test:///r", Text: "hi"}}}, nil
})
// Cancelling the listen handler's context is the only way application code
// has to end one subscription stream.
server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
if method != "subscriptions/listen" {
return next(ctx, method, req)
}
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
mu.Lock()
endStream = cancel
mu.Unlock()
return next(streamCtx, method, req)
}
})
var wire bytes.Buffer
ct, st := mcp.NewInMemoryTransports()
if _, err := server.Connect(ctx, st, nil); err != nil {
log.Fatal(err)
}
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "1"}, nil)
cs, err := client.Connect(ctx, &mcp.LoggingTransport{Transport: ct, Writer: &wire}, nil)
if err != nil {
log.Fatal(err)
}
if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "test:///r"}); err != nil {
log.Fatal(err)
}
time.Sleep(300 * time.Millisecond)
mu.Lock()
end := endStream
mu.Unlock()
end() // the server tears the subscription down
time.Sleep(300 * time.Millisecond)
fmt.Print(wire.String())
}
The last two frames, after the discover handshake and the listen request:
read: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":2},"notifications":{"resourceSubscriptions":["test:///r"]}}}
read: {"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"server","version":"1"},"io.modelcontextprotocol/subscriptionId":2}}}
The graceful result arrives and no notifications/cancelled is written, before it or after it, with the connection still open. Replacing the teardown with ServerSession.Close gives the same answer: zero cancelled frames.
Against main at 5bc078a with go1.27.1 on linux/amd64. I first saw it on v1.7.0, which is what my server pins. That server honours subscriptions by polling, so teardown on the server's own initiative is routine rather than exceptional: a watch retires when its resource stops being readable, and the credential behind it can be evicted while the client is still listening. Over stdio, when a watch retired on a 404, the client received the listen request's own result and nothing else, before it or after it, with the session still usable.
What a server built on the SDK cannot do today
It cannot satisfy that MUST at all. Ending a subscription is possible and the SHOULD comes for free, because the SDK writes the completion result when the handler's context ends. The required notification is not sendable through any exported API.
The machinery is all there, which is what makes this feel like a missing entry point rather than a missing feature. CancelledParams is exported and constructible (mcp/protocol.go:419), and defaultSendingMethodHandler forwards any method beginning with notifications/ straight to conn.Notify (mcp/shared.go:139, the branch at mcp/shared.go:154), with notifications/cancelled present in the server's sending table. The only way I found to reach that from outside the package is to capture the next mcp.MethodHandler handed to an AddSendingMiddleware middleware and call it later with the method name and a hand-built *mcp.ServerRequest[*mcp.CancelledParams]. Called in place of the teardown in the program above, that does put the frame on the wire, and the client reads {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"reason":"probe teardown","requestId":2}}. I am not shipping it: it depends on undocumented dispatch behaviour, it needs a middleware installed for no reason other than to steal a handler, and it gets the routing wrong.
The routing is the part I think settles where this belongs. In (*streamableServerConn).Write (mcp/streamable.go:1817), a message that is not a response is correlated to a stream through the request ID in its context (mcp/streamable.go:1847), and with no ID there it falls back to the first stream that happens to be a listen (mcp/streamable.go:1870). A session with two concurrent listens can therefore have a cancellation delivered on the wrong stream. Only the SDK holds the request to stream table that gets this right, so the notification should be sent by the SDK, not assembled by me.
Proposal
One method, mirroring NotifyProgress:
// NotifyCancelled tells the client that the server has torn down the
// subscriptions/listen stream identified by params.RequestID, as required by
// protocol version 2026-07-28.
//
// The specification permits a server-sent cancellation for no other purpose,
// so RequestID must identify a subscriptions/listen request that is in flight
// on this session; NotifyCancelled reports an error otherwise. If RequestID is
// nil it is taken from the incoming request ID in ctx, so a handler or
// middleware serving the listen can call this without tracking IDs of its own.
func (ss *ServerSession) NotifyCancelled(ctx context.Context, params *CancelledParams) error
Everything it needs already exists in the package:
- The request ID comes from
params.RequestID, and when that is nil from the incoming request ID the SDK already puts in the handler context at mcp/server.go:2010. A caller that is not on the listen's context can obtain the value from the acknowledged notification's_metaunderMetaKeySubscriptionID, which the SDK already sends and which a sending middleware sees. - The validation is against
ServerSession.listenIDs(mcp/server.go:1566), the list the SDK already maintains forClose. An ID that is not an in-flight listen on this session is refused, which is what turns the specification's MUST NOT into something a caller cannot break by accident. - The
_metagetsMetaKeySubscriptionIDthroughinjectMetaSubscriptionID(mcp/server.go:832), like every other message on the stream, so a stdio client can demultiplex it. - The send itself is
handleNotify(ctx, notificationCancelled, newServerRequest(ss, params)), the same one-line body asNotifyProgressat mcp/server.go:1520. Since the method knows the ID, it can also put it into the context it notifies with, underidContextKey, so the notification is routed to that listen's own stream instead of to whichever stream the fallback at mcp/streamable.go:1870 happens to pick.
In my server the call would sit in the middleware that already wraps subscriptions/listen, immediately before the cancel that ends the stream, so the client receives the cancellation and then the completion result.
If you would rather the name state the only purpose the specification allows, EndSubscription or CancelListen would read better than NotifyCancelled and the body is identical. That is your call and I have no preference worth arguing.
Alternatives I considered
The SDK sends it itself and no API is added. (*Server).subscriptionsListen would send the notification just before returning its result, and Close for each ID it cancels. Every server would conform with no user action, which I like. Two things stopped me proposing it on its own. The SDK cannot currently tell a server-initiated teardown from the client's own cancellation, since both arrive as a cancelled context, and the notification is owed only for the first; the cancel-cause change I have open for #1254 would make them distinguishable, so this becomes feasible on top of that rather than before it. And the reason string would be context canceled, whereas an application that retires a watch knows something a reader can act on. I would be glad to see this done as well, calling the same method internally.
Make the result constructible and let application code complete the listen. Exporting completeResultWithType, or adding a stream handle with a Complete method, is a larger surface and it addresses the SHOULD that already works rather than the MUST that does not.
Expose only the request ID, option 3 of your own note at mcp/streamable.go:1183. Useful on its own merits, but half a fix here: there would still be no send path, and handing applications the ID without a validated send invites exactly the cancellations the specification forbids. With the validation above, no accessor is needed.
A teardown callback in ServerOptions. More surface for no more power. The decision to end a subscription lives in the application, so the application still has to be the one that speaks.
I prefer the method because it is one exported symbol, it adds no new types, it reuses the ID bookkeeping Close already depends on, and it is the primitive the automatic behaviour above would call anyway.
One note on the receiving side
For what it is worth, a server-sent cancellation reaching this SDK's client today is preempted by canceller.Preempt (mcp/transport.go:259) and resolved with Connection.Cancel (internal/jsonrpc2/conn.go:455), which looks up incoming requests only, so the client's own outgoing listen call is not affected and the notification is effectively ignored. That does not change the server-side obligation, and other clients implementing 2026-07-28 will act on it, but if you want the round trip closed for SDK to SDK sessions the client would also need to treat a cancellation naming its own in-flight listen as the end of that stream. Happy to file that separately rather than widen this.
Happy to send the PR once you say which shape you want. For the one above that is the single method, the validation against listenIDs, the _meta stamp and the routing, plus a test on the in-memory transport asserting both that the frame goes out on teardown and that an ID which is not an in-flight listen is refused.
Part of #1257.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with NotifyProgress and subscriptionsListen in mcp/server.go, then trace streamableServerConn.Write in mcp/streamable.go to understand request-stream routing. Use the supplied in-memory reproduction while working. Done means an exported server-session API can send notifications/cancelled for an in-flight subscriptions/listen request, routes it to the correct stream, and rejects invalid request IDs.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100