Expose the middleware chain on MCPServer and stop sending unrequested change notifications (#3201)
This commit is contained in:
parent
528e366558
commit
c9c431b71a
3 changed files with 234 additions and 28 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue