Expose the middleware chain on MCPServer and stop sending unrequested change notifications (#3201)

This commit is contained in:
Max 2026-07-28 12:24:23 +01:00 • committed by GitHub
commit c9c431b71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 234 additions and 28 deletions

View file

@ -49,6 +49,7 @@ from typing_extensions import deprecated
from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
from mcp.shared.peer import Meta, dump_params
from mcp.shared.subscriptions import LISTEN_STREAM_METHODS
__all__ = ["Connection"]
@ -159,12 +160,25 @@ class NotifyOnlyOutbound(_NoChannelOutbound):
over duplex stream transports: the pipe is real, so server notifications
ride it, but the modern protocol forbids server-initiated JSON-RPC
requests, so `send_raw_request` (inherited) refuses by construction.
Change notifications (`notifications/*/list_changed`,
`notifications/resources/updated`) are dropped with a debug log: at this
era they reach a client only through a `subscriptions/listen` stream it
opened, so a bare copy on the shared channel would be an unrequested
notification. Publish them on the server's `SubscriptionBus` instead.
"""
def __init__(self, outbound: Outbound) -> None:
self._outbound = outbound
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
# At the 2026-07-28 era these are `subscriptions/listen` stream goods
# only: the spec forbids sending a change notification a subscription
# did not request, and listen streams deliver them (stamped, filtered)
# via the request-scoped outbound, never this connection-scoped channel.
if method in LISTEN_STREAM_METHODS:
logger.debug("dropped %s: delivered via subscriptions/listen at this era", method)
return
await self._outbound.notify(method, params, opts)

View file

@ -59,7 +59,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAut
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.caching import CacheableMethod, CacheHint
from mcp.server.context import HandlerResult, ServerRequestContext
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.extension import (
Extension,
MethodBinding,
@ -172,6 +172,7 @@ class MCPServer(Generic[LifespanResultT]):
request_state_security: RequestStateSecurity | None = None,
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
subscriptions: SubscriptionBus | None = None,
middleware: Sequence[ServerMiddleware[Any]] | None = None,
):
self._resource_security = resource_security
self.settings = Settings(
@ -228,6 +229,9 @@ class MCPServer(Generic[LifespanResultT]):
raise ValueError(_MISSING_AUDIENCE)
security = request_state_security
self._lowlevel_server.middleware.append(RequestStateBoundary(security, default_audience=self.name))
# User middleware runs inside the SDK's built-ins (OpenTelemetry, then the
# request-state boundary), outermost-first in the order given.
self._lowlevel_server.middleware.extend(middleware or ())
# Validate auth configuration
if self.settings.auth is not None:
if auth_server_provider and token_verifier: # pragma: no cover
@ -257,6 +261,17 @@ class MCPServer(Generic[LifespanResultT]):
def name(self) -> str:
return self._lowlevel_server.name
@property
def middleware(self) -> list[ServerMiddleware[Any]]:
"""The middleware chain wrapping every inbound message, outermost-first.
The same list as the low-level `Server.middleware`: append an
`async (ctx, call_next)` callable to observe, refuse, or rewrite
messages before they reach a handler. Provisional - the signature is
expected to change before v2 is final; see the middleware guide.
"""
return self._lowlevel_server.middleware
@property
def title(self) -> str | None:
return self._lowlevel_server.title

View file

@ -21,6 +21,7 @@ from mcp_types import (
)
__all__ = [
"LISTEN_STREAM_METHODS",
"SUBSCRIPTION_ID_META_KEY",
"PromptsListChanged",
"ResourceUpdated",
@ -79,6 +80,10 @@ _LIST_CHANGED_EVENTS: dict[str, ServerEvent] = {
"notifications/resources/list_changed": ResourcesListChanged(),
}
LISTEN_STREAM_METHODS: frozenset[str] = frozenset({*_LIST_CHANGED_EVENTS, "notifications/resources/updated"})
"""The notification methods that ride `subscriptions/listen` streams at 2026-07-28
(and, at that era, nowhere else): the change-notification vocabulary."""
def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None:
"""The event a raw listen-stream frame announces, or None if it carries none.