Add a subscription authorization hook and stop sending unrequested change notifications

Closed
MA
3mo ago
+237-22

Two gaps on the 2026-07-28 subscriptions/listen server path, fixed together because either one alone leaves the other's exposure open.

Motivation and Context

No per-caller authorization on subscribe. A client can name any resource URI in its subscriptions/listen filter and the server honors it verbatim — there was no hook to refuse a subscription per caller, so a caller your resources/read handler would deny could still subscribe to that URI and observe its change events (URIs and activity timing; change notifications carry no content). This adds an optional authorize hook on ListenHandler (and MCPServer(authorize_subscriptions=...)) that runs once, before the acknowledgment, with the request context and the requested filter. It either returns (accept the request as asked) or raises MCPError (refuse the whole request in-band; no ack, no stream). It deliberately does not narrow the filter — the acknowledged subset stays a support contract, not an authorization verdict, so a client is never handed a per-URI oracle. Any other exception fails closed rather than granting. Default behavior (no hook) is unchanged.

Unrequested change notifications on modern connections. The modern-era standalone channel (NotifyOnlyOutbound) forwarded every notification, so ctx.session.send_tool_list_changed() on a 2026-07-28 stdio connection wrote a bare, unstamped notifications/tools/list_changed that no subscription had requested — bypassing subscribe-time authorization entirely. That channel now drops the four change-notification methods with a debug log; at this era they reach clients only through listen streams, which stamp and filter them. Publish via ctx.notify_* / the SubscriptionBus.

Docs: the multi-tenant warning on the subscriptions page is replaced with a section on the hook (accept-or-refuse, keep refusals uniform so they name no URI, the verdict holds for the stream's lifetime), plus a migration-guide entry for the notification-routing change.

How Has This Been Tested?

Unit tests for the hook (accept passes the request through unchanged, MCPError refusal happens pre-ack with no ack sent, other exceptions fail closed) and for the dropped notification methods. The earlier narrowing iteration was also driven end to end over real stdio with raw JSON-RPC frames (in-band refusal, fail-closed error, and no bare list_changed after a publish all confirmed on the wire); the accept-or-refuse rework re-verification is in progress.

Breaking Changes

ctx.session.send_tool_list_changed() / send_prompt_list_changed() / send_resource_list_changed() / send_resource_updated() are now dropped (debug-logged) on 2026-07-28 stdio connections instead of being written as bare frames; pre-2026 connections are unaffected, and modern HTTP already had no standalone channel to write to. Migrate to the notify_* bus methods — documented in docs/migration.md.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The spec is silent on filter authorization, and no other SDK offers a per-caller seam on the listen path (the TypeScript SDK narrows the ack by advertised capabilities only; Go has a reject-only subscribe hook; C#'s per-URI hook is not wired to the 2026 path), so accept-or-refuse is the smallest surface that closes the gap without inventing narrowing semantics the spec does not license.

AI Disclaimer