Skip to content

mcp: list_changed can only be broadcast; a server cannot notify one session (2026-07-28 subscriptions/listen) #1480

Open

Description

@huynle-dev

#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 on AddTool/RemoveTool/AddPrompt/…. After debouncing it calls notifySessions, which sends to every legacy session and every 2026-07-28 session in toolChangeSubscriptions / promptChangeSubscriptions / resourceChangeSubscriptions.
  • The per-session sender notifySubscribedSessions (:810) and the listen-request ID stamped under MetaKeySubscriptionID (injectMetaSubscriptionID, :832) are unexported. ServerSession exports NotifyProgress, NotifyElicitationComplete, Log, … but no list-changed sender.
  • TestSubscriptionsListen_MultipleSessions asserts 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:

  1. AddTool/RemoveTool a dummy tool plus a sending middleware that drops the notification for other principals (the Proposal: support for sending tools/list_changed notifications 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).
  2. One *mcp.Server per principal from getServer, diffing its tool table: duplicates the catalog per principal just to reach the notifier.
  3. Writing the frame ourselves is impossible: the listen request ID is unreachable (idContextKey and 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) error

Implementation: 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

  1. guglielmo-san commented on Nov 2, 2026

    @guglielmo-san
    Contributor

    @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.

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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions