Repository navigation
Description
#666 asked for a way to send notifications/tools/list_changed to a specific client and was closed as obsolete under 2026-07-28. The transport changed (subscriptions/listen replaced the standalone GET stream), but delivery did not: in v1.8.0 (and on main @ eafb671) a server still cannot send a list-changed notification to one session.
Today
- The only trigger is
Server.changeAndNotify(mcp/server.go:747, v1.8.0), run onAddTool/RemoveTool/AddPrompt/…. After debouncing it callsnotifySessions, which sends to every legacy session and every 2026-07-28 session intoolChangeSubscriptions/promptChangeSubscriptions/resourceChangeSubscriptions. - The per-session sender
notifySubscribedSessions(:810) and the listen-request ID stamped underMetaKeySubscriptionID(injectMetaSubscriptionID,:832) are unexported.ServerSessionexportsNotifyProgress,NotifyElicitationComplete,Log, … but no list-changed sender. TestSubscriptionsListen_MultipleSessionsasserts the fan-out.
Use case
We run a multi-tenant MCP gateway on go-sdk: one stateless *mcp.Server behind several replicas. Each principal sees a different tool/prompt/resource list (registry + policy engine + OAuth scopes). A receiving middleware serves tools/list and tools/call; the SDK tool table stays empty, because per-principal servers with ~1000 tools each are not viable (#1265 has the same constraint).
When a principal's visible set changes, only that principal's open subscriptions/listen streams should get list_changed. Clients cache catalogs, so without it they keep calling tools that are gone and never see new ones.
The SDK already accepts the listen, acks it, records req.Session → requestID, forces SSE and retires the subscription. The missing piece is the last step: send this notification to this session's listen.
Workarounds, each with a problem:
AddTool/RemoveToola dummy tool plus a sending middleware that drops the notification for other principals (the Proposal: support for sendingtools/list_changednotifications manually #666 hack): fans out N notifications to drop N−1, and the routing key has to be smuggled through_meta(no ctx propagation, mcp: a resource update cannot be delivered to one session, and the caller's context never reaches the sending middleware #1265).- One
*mcp.Serverper principal fromgetServer, diffing its tool table: duplicates the catalog per principal just to reach the notifier. - Writing the frame ourselves is impossible: the listen request ID is unreachable (
idContextKeyand the subscription tables are unexported).
Proposal
// NotifyToolListChanged sends notifications/tools/list_changed to this session.
// For a 2026-07-28 session it is delivered on the session's active
// subscriptions/listen stream that opted into toolsListChanged, with that
// listen's request ID under _meta[MetaKeySubscriptionID]. If no such listen is
// open, it does nothing and returns nil. Legacy sessions receive it on the
// standalone stream, as changeAndNotify does today.
func (ss *ServerSession) NotifyToolListChanged(ctx context.Context, params *ToolListChangedParams) error
func (ss *ServerSession) NotifyPromptListChanged(ctx context.Context, params *PromptListChangedParams) error
func (ss *ServerSession) NotifyResourceListChanged(ctx context.Context, params *ResourceListChangedParams) errorImplementation: lock s.mu, look up s.toolChangeSubscriptions[ss], unlock, copy params, injectMetaSubscriptionID, then handleNotify with the caller's ctx. Server.notifySessions can become a loop over these (one delivery path; composes with #1265).
If three methods is too much surface (the concern raised in #666), a single func (ss *ServerSession) NotifyListChanged(ctx context.Context, kind string) error would also work.
Usage:
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" {
ss := req.GetSession().(*mcp.ServerSession)
stop := watchPrincipal(ctx, principalFrom(ctx), func() {
_ = ss.NotifyToolListChanged(ctx, &mcp.ToolListChangedParams{})
})
defer stop()
}
return next(ctx, method, req) // SDK registers, acks, blocks until the stream ends
}
})Open questions: (a) "no open listen" → nil (our preference) or a sentinel error? (b) keep the shouldSendListChangedNotification capability gate (we'd say yes).
Happy to send a PR with tests.
Activity
guglielmo-san commentedon Nov 2, 2026
@huynle-dev I think it is fine to expose the 3 functions, but I would not modify Server.notifySessions for now.
Feel free to send the PR.