Expose the middleware chain on MCPServer and stop sending unrequested change notifications

Merged
MA
3mo ago
+234-28

Two related changes on the 2026-07-28 subscriptions/listen server path. Supersedes #3197 (a subscriptions-specific authorization hook), taking the smaller route of reusing the existing middleware seam instead of adding a policy callback.

Motivation and Context

Per-caller authorization on subscribe. A client can name any resource URI in its subscriptions/listen filter and the server honors it — nothing consults the read handler, because nothing is being read — so a caller your resources/read handler would refuse could still subscribe to that URI and observe its change events (URIs and activity timing; change notifications carry no content). Rather than invent a subscriptions-only hook, this exposes the existing low-level middleware chain on MCPServer: MCPServer(name, middleware=[...]) at construction and mcp.middleware (the same live list as the low-level Server.middleware). Refusing a listen request per caller is then an ordinary middleware that raises MCPError before calling call_next(ctx) — in-band error, no acknowledgment, no stream, connection intact. A tested docs tutorial (docs_src/subscriptions/tutorial006.py) shows the pattern: an explicit access table, one can_access(user, uri) shared by the read handler and the middleware, so read and watch authorization stay in step and swap together for a database or RBAC lookup. The middleware seam stays marked provisional; the docs now name observe and refuse as its intended uses.

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 filtering entirely. That channel now drops the four change-notification methods with a debug log (the method set is single-sourced from mcp.shared.subscriptions); at this era they reach clients only through listen streams, which stamp and filter them. Publish via ctx.notify_* / the SubscriptionBus.

How Has This Been Tested?

Unit tests for the middleware kwarg/property wiring, a middleware refusing subscriptions/listen pre-ack over an in-memory client, the dropped notification methods, and the docs tutorial end to end (accepted read/listen, refused listen, refused read). Also driven over real stdio with raw JSON-RPC frames: acknowledged listen, only stamped stream frames after a publish (no bare list_changed), in-band refusal with no ack, and a middleware that itself throws surfacing as an internal error without granting.

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. MCPServer(middleware=...) is additive.

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 today; refusing the whole request in middleware (as opposed to narrowing the acknowledged filter) avoids inventing narrowing semantics the spec does not yet license, and keeps the refusal from becoming a per-URI existence probe.

AI Disclaimer