Skip to content

mcp: a resource update cannot be delivered to one session, and the caller's context never reaches the sending middleware #1265

Description

@jmrplens

I found this auditing my MCP server against the specification. Every line number below is from main at 5bc078a, and I checked each one against that tree.

What the protocol scopes to one subscriber

On 2025-06-18 a subscription is a relationship between one client and one URI: "Clients can subscribe to specific resources and receive notifications when they change". The same page puts the access decision on the server, per resource: "Access controls SHOULD be implemented for sensitive resources" and "Resource permissions SHOULD be checked before operations".

On 2026-07-28 the scoping is explicit. SEP-2575 removed resources/subscribe and says clients "declare the resources they want updates for in the notifications param of the subscriptions/listen request. The server sends notifications/resources/updated on the listen stream for matching resources." Each subscription is identified by the JSON-RPC request id of its listen request, and "every notification delivered as part of an active subscription MUST include the subscription's request ID in _meta".

In both revisions the unit of delivery is one subscriber's subscription, and the SDK models the subscribing side exactly that way. ServerOptions.SubscribeHandler (mcp/server.go:109) is called with the session that subscribed, and the table it fills is resourceSubscriptions map[string]map[*ServerSession]jsonrpc.ID, "uri -> session -> requestID" (mcp/server.go:63, written at mcp/server.go:1228).

What the SDK offers on the delivery side

Server.ResourceUpdated (mcp/server.go:1183) is the only exported way to send notifications/resources/updated. It takes the whole subscriber set for the URI (mcp/server.go:1185), splits it by protocol version (mcp/server.go:1189-1195) and notifies all of it: legacy sessions through notifySessions (called at mcp/server.go:1198, defined at mcp/shared.go:472), and 2026-07-28 sessions through notifySubscribedSessions (called at mcp/server.go:1201, defined at mcp/server.go:810). docs/server.md:401-402 states it plainly: it "notifies every session subscribed to that URI".

ServerSession has no equivalent. Its exported senders are NotifyProgress (mcp/server.go:1520), Ping (:1631), ListRoots (:1643), CreateMessage (:1664), CreateMessageWithTools (:1710), Elicit (:1729), NotifyElicitationComplete (:1796) and Log (:1817). Nothing sends a resource update.

Application code cannot assemble the 2026-07-28 frame on its own either, because the one value that frame requires is not reachable from outside the package. The listen request id is read from ctx.Value(idContextKey{}) at subscribe time (mcp/server.go:1212) and stored in the unexported subscription table; idContextKey is unexported (mcp/streamable.go:1188); injectMetaSubscriptionID is unexported (mcp/server.go:832); and ServerRequest (mcp/shared.go:623) carries Session, Params and Extra, but not the request id. MetaKeySubscriptionID is exported (mcp/protocol.go:2376), and the value that belongs under it is not.

The caller's context does not reach the sender either. Both delivery paths build a fresh one: mcp/shared.go:479 and mcp/server.go:814 each do context.WithTimeout(context.Background(), 10*time.Second). Server.ResourceUpdated accepts a ctx at mcp/server.go:1183 and its body never uses it, so nothing a caller attaches to that context is visible to the sending middleware that eventually writes the frame (mcp/shared.go:179, mcp/shared.go:130).

The observation that found it

I share one mcp.Server between credentials: one server per configuration shape, with the GitLab client bound per request, because building a catalog of about a thousand tools per token is not viable. Resource watchers are per credential, since the first read of a resource is the authorization check. Delivery turned out to be the only part of that design with no per-credential seam. The SDK's subscription table is keyed by URI and session and has no notion of a credential, so two credentials subscribed to the same resource each receive the other's notifications, produced by the other's polling, and a credential whose access was revoked keeps being told the resource changed by somebody else's watcher.

I am not presenting that as a defect in the SDK's intended design. A single-tenant server is served correctly by the fan-out, and the fan-out is the natural reading of the older revision. It is a deployment the exported API has no way to express.

The shape reproduces with two ordinary sessions:

server := mcp.NewServer(&mcp.Implementation{Name: "s", Version: "v1"}, &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: "file:///r1"}, readR1)

csA := connect(server) // csA.Subscribe(ctx, &mcp.SubscribeParams{URI: "file:///r1"})
csB := connect(server) // csB.Subscribe(ctx, &mcp.SubscribeParams{URI: "file:///r1"})

// A's watcher found the change. There is no way to say "tell A".
server.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "file:///r1"})
// Both ResourceUpdatedHandlers fire.

TestSubscriptionsListen_MultipleSessions in mcp/mcp_test.go already asserts that shape for the list-changed notifications, under the comment "A single change fans out to BOTH sessions, each tagged with that session's own ack ID".

What a server built on the SDK cannot do today

It cannot deliver a resource update to a subset of the sessions subscribed to a URI, on either protocol revision.

It cannot give a delivery a deadline, a cancellation, a trace span or a request-scoped logger, because the context the sender sees is a fresh context.Background() with a fixed ten second timeout.

What it can do is filter on the way out, which is what I ship today: a sending middleware added with AddSendingMiddleware (mcp/server.go:1845) sees req.GetSession() and can drop the notification for a session it does not want. That works, and it has two sharp edges worth naming because both were bugs for me first.

The routing key has to travel in the params, since the context cannot carry it. I stamp the owning pool entry into the notification's _meta before calling ResourceUpdated, filter on it in the middleware, and hand the frame on with a copy of the map that no longer holds my private key. A key in _meta is not where a routing decision belongs, and the notification is built and marshalled before it is dropped.

A middleware also has to read the params through the interface. The two paths instantiate the request differently: notifySessions is called with a *ResourceUpdatedNotificationParams (mcp/server.go:1198), so newRequest yields *ServerRequest[*ResourceUpdatedNotificationParams], while notifySubscribedSessions calls makeParams() Params (mcp/server.go:810, :817-819), so the same notification arrives as *ServerRequest[Params]. A type assertion on either concrete type silently matches one revision and drops the other. req.GetParams() is the only portable read.

Proposal

I would like a per-session sender, symmetric with the ones ServerSession already has:

// ResourceUpdated notifies the client on this session that the resource
// identified by params.URI has changed. If this session is not subscribed to
// that URI, ResourceUpdated does nothing and returns nil.
func (ss *ServerSession) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNotificationParams) error

What it reads, and from where: it takes ss.server.mu, looks up ss.server.resourceSubscriptions[params.URI][ss] (mcp/server.go:63), and releases the lock before sending. If the session is absent it returns nil. If present, it chooses the frame the way Server.ResourceUpdated already chooses it at mcp/server.go:1189-1195: a session below protocolVersion20260728 gets the legacy notification, and a 2026-07-28 session gets a copy of the params with that session's own recorded request id stamped by injectMetaSubscriptionID (mcp/server.go:832). It then calls handleNotify with the caller's ctx, so the middleware sees the context the caller built.

Server.ResourceUpdated can then be expressed as a loop over the subscriber set calling the per-session method, which keeps its behaviour and gives the fan-out path the caller's context as well. That also composes with #1227 and #1228: whoever makes delivery concurrent has one place to do it, and a server author who wants per-session deadlines can build them without waiting for that.

Two semantics I would want maintainers to settle rather than assume: whether "not subscribed" should be a nil return or an exported sentinel error, and whether the method should be allowed on a session that has an entry for the URI recorded by subscribe but no live listen stream. My own preference is nil for the first, matching Server.ResourceUpdated returning nil when nobody is subscribed, and the second is answered by the table, since mcp/server.go:1245-1250 removes the entry when the subscription ends.

The second, independent change is to propagate the caller's context. Both signatures are unexported, so this is not an API break:

func notifySessions[S Session, P Params](ctx context.Context, sessions []S, method string, params P, logger *slog.Logger)
func (s *Server) notifySubscribedSessions(ctx context.Context, subscribers map[*ServerSession]jsonrpc.ID, method string, makeParams func() Params)

Server.ResourceUpdated already has a ctx to pass. The ten second bound would become context.WithTimeout(ctx, 10*time.Second), so a caller can shorten it and cancellation reaches the sender. One caller has no caller context to offer: changeAndNotify fires s.notifySessions from a time.AfterFunc (mcp/server.go:761), and that path keeps context.Background(). It is also a behaviour change worth being explicit about, since a caller who cancels would now stop delivery mid fan-out where today it always completes.

Alternatives I considered

A generic ServerSession.SendNotification, as asked for in #745 and implemented in #1146, does not close this. The send side there wraps a custom method behind the x-notifications/ prefix, and SendSubscriptionNotification reads the subscription id out of the caller's context, so it works from inside the listen handler and not from the goroutine that noticed the resource changed. Neither sends the standard notifications/resources/updated, and neither consults the subscription table, so a raw sender would happily deliver to a session that never subscribed. I think the two are complementary rather than overlapping.

Exposing the table instead, something like Server.Subscribers(uri string) iter.Seq[*ServerSession], would let a caller pick recipients but leave it unable to build the 2026-07-28 frame for them, which is the half that cannot be done from outside the package. It would pair with the sender above, not replace it.

A recipient filter in ServerOptions, a callback consulted per session per notification, keeps delivery inside the SDK but puts the policy in a place a reader has to go looking for, and it still gives the caller no way to say which update this is. A sender the caller drives is easier to reason about.

Keeping my middleware is the do-nothing option. It works today and I am not blocked. What I dislike about it is that a routing decision travels in _meta on the wire format, and that its correctness depends on two details of the delivery paths that are not part of the documented API.

Of these I prefer the per-session sender, because it puts the choice of recipient where the caller already knows the answer, it needs no _meta key of its own, and it makes the context propagation fall out for the path that matters most to me. The context change is worth doing on its own merits even if the sender is rejected, and the two are independent.

I am happy to open a pull request for either shape, with tests, once you say which one you want. If the answer is that a multi-credential server should not be sharing one mcp.Server in the first place, I would rather hear that than send code.

Part of #1257.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions