Skip to content

Commit cdc78fc

Browse files
committed
Keep the public server signatures resolvable by typing.get_type_hints
Moving the HTTP transport and auth imports under TYPE_CHECKING made typing.get_type_hints() raise NameError on public methods it used to resolve on: MCPServer.streamable_http_app / sse_app / run_sse_async / run_streamable_http_async / session_manager and Server.streamable_http_app / session_manager (EventStore, TransportSecuritySettings, StreamableHTTPSessionManager, AuthSettings, OAuthAuthorizationServerProvider, TokenVerifier were typing-only names in real annotations). Two of those types get web-framework-free homes so the server modules can import them for real without loading starlette: the resumability contract (EventStore, EventMessage, EventCallback, EventId, StreamId) moves to mcp.server.event_store, and mcp.server.transport_security keeps only the pydantic TransportSecuritySettings while its starlette middleware moves beside the transports. Both old import paths keep working with the same objects. The session manager and the OAuth annotations are spelled through the lazy mcp.server namespace instead (the trick already used for Client.server), so they evaluate on demand and MCPServer stops importing the OAuth provider stack at module load. The starlette-owned annotations of the app builders (Starlette, Route, Request/Response) stay typing-only; get_type_hints on those methods needs starlette's names supplied by the caller.
1 parent 1f3fbc7 commit cdc78fc

9 files changed

Lines changed: 308 additions & 171 deletions

File tree

‎src/mcp/server/_streamable_http_modern.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@
4747
from starlette.responses import Response
4848
from starlette.types import Receive, Scope, Send
4949

50+
from mcp.server._transport_security_middleware import TransportSecurityMiddleware
5051
from mcp.server.connection import Connection
5152
from mcp.server.runner import modern_error_data, serve_one
5253
from mcp.server.streamable_http import check_accept_headers
53-
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
54+
from mcp.server.transport_security import TransportSecuritySettings
5455
from mcp.shared.dispatcher import CallOptions
5556
from mcp.shared.exceptions import NoBackChannelError
5657
from mcp.shared.inbound import (
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Starlette middleware that enforces `TransportSecuritySettings` on HTTP transports.
2+
3+
Kept apart from `mcp.server.transport_security` (which defines only the
4+
web-framework-free settings model, so `import mcp.server` and stdio servers
5+
never load starlette). `mcp.server.transport_security` still re-exports
6+
`TransportSecurityMiddleware` for existing imports of it.
7+
"""
8+
9+
import logging
10+
11+
from starlette.requests import Request
12+
from starlette.responses import Response
13+
14+
from mcp.server.transport_security import TransportSecuritySettings
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
# TODO(Marcelo): This should be a proper ASGI middleware. I'm sad to see this.
20+
class TransportSecurityMiddleware:
21+
"""Middleware to enforce DNS rebinding protection for MCP transport endpoints."""
22+
23+
def __init__(self, settings: TransportSecuritySettings | None = None):
24+
# If not specified, disable DNS rebinding protection by default for backwards compatibility
25+
self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False)
26+
27+
def _validate_host(self, host: str | None) -> bool:
28+
"""Validate the Host header against allowed values."""
29+
if not host:
30+
logger.warning("Missing Host header in request")
31+
return False
32+
33+
# Check exact match first
34+
if host in self.settings.allowed_hosts:
35+
return True
36+
37+
# Check wildcard port patterns
38+
for allowed in self.settings.allowed_hosts:
39+
if allowed.endswith(":*"):
40+
# Extract base host from pattern
41+
base_host = allowed[:-2]
42+
# Check if the actual host starts with base host and has a port
43+
if host.startswith(base_host + ":"):
44+
return True
45+
46+
logger.warning(f"Invalid Host header: {host}")
47+
return False
48+
49+
def _validate_origin(self, origin: str | None) -> bool:
50+
"""Validate the Origin header against allowed values."""
51+
# Origin can be absent for same-origin requests
52+
if not origin:
53+
return True
54+
55+
# Check exact match first
56+
if origin in self.settings.allowed_origins:
57+
return True
58+
59+
# Check wildcard port patterns
60+
for allowed in self.settings.allowed_origins:
61+
if allowed.endswith(":*"):
62+
# Extract base origin from pattern
63+
base_origin = allowed[:-2]
64+
# Check if the actual origin starts with base origin and has a port
65+
if origin.startswith(base_origin + ":"):
66+
return True
67+
68+
logger.warning(f"Invalid Origin header: {origin}")
69+
return False
70+
71+
def _validate_content_type(self, content_type: str | None) -> bool:
72+
"""Validate the Content-Type header for POST requests."""
73+
return content_type is not None and content_type.lower().startswith("application/json")
74+
75+
async def validate_request(self, request: Request, is_post: bool = False) -> Response | None:
76+
"""Validate request headers for DNS rebinding protection.
77+
78+
Returns None if validation passes, or an error Response if validation fails.
79+
"""
80+
# Always validate Content-Type for POST requests
81+
if is_post:
82+
content_type = request.headers.get("content-type")
83+
if not self._validate_content_type(content_type):
84+
return Response("Invalid Content-Type header", status_code=400)
85+
86+
# Skip remaining validation if DNS rebinding protection is disabled
87+
if not self.settings.enable_dns_rebinding_protection:
88+
return None
89+
90+
# Validate Host header
91+
host = request.headers.get("host")
92+
if not self._validate_host(host):
93+
return Response("Invalid Host header", status_code=421)
94+
95+
# Validate Origin header
96+
origin = request.headers.get("origin")
97+
if not self._validate_origin(origin):
98+
return Response("Invalid Origin header", status_code=403)
99+
100+
return None

‎src/mcp/server/event_store.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Event storage interface for streamable-HTTP resumability.
2+
3+
`EventStore` and its supporting types are the contract a resumable
4+
streamable-HTTP server plugs a store into (see `mcp.server.streamable_http`,
5+
which re-exports every name here). They are defined in this transport-agnostic
6+
module - no web framework imported - so servers and application code can name
7+
and type against them without loading the HTTP stack.
8+
"""
9+
10+
from abc import ABC, abstractmethod
11+
from collections.abc import Awaitable, Callable
12+
from dataclasses import dataclass
13+
14+
from mcp_types import JSONRPCMessage
15+
16+
__all__ = ["EventCallback", "EventId", "EventMessage", "EventStore", "StreamId"]
17+
18+
# Type aliases
19+
StreamId = str
20+
EventId = str
21+
22+
23+
@dataclass
24+
class EventMessage:
25+
"""A JSONRPCMessage with an optional event ID for stream resumability."""
26+
27+
message: JSONRPCMessage
28+
event_id: str | None = None
29+
30+
31+
EventCallback = Callable[[EventMessage], Awaitable[None]]
32+
33+
34+
class EventStore(ABC):
35+
"""Interface for resumability support via event storage."""
36+
37+
@abstractmethod
38+
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
39+
"""Stores an event for later retrieval.
40+
41+
Args:
42+
stream_id: ID of the stream the event belongs to
43+
message: The JSON-RPC message to store, or None for priming events
44+
45+
Returns:
46+
The generated event ID for the stored event.
47+
"""
48+
pass # pragma: no cover
49+
50+
@abstractmethod
51+
async def replay_events_after(
52+
self,
53+
last_event_id: EventId,
54+
send_callback: EventCallback,
55+
) -> StreamId | None:
56+
"""Replays events that occurred after the specified event ID.
57+
58+
Args:
59+
last_event_id: The ID of the last event the client received
60+
send_callback: A callback function to send events to the client
61+
62+
Returns:
63+
The stream ID of the replayed events, or None if no events were found.
64+
"""
65+
pass # pragma: no cover

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,30 +50,29 @@ async def main():
5050
from pydantic import BaseModel
5151
from typing_extensions import TypeVar, deprecated
5252

53+
import mcp
5354
from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE
5455
from mcp.server._otel import OpenTelemetryMiddleware
5556
from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints
5657
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
58+
from mcp.server.event_store import EventStore
5759
from mcp.server.models import InitializationOptions
5860
from mcp.server.runner import serve_dual_era_loop
61+
from mcp.server.transport_security import TransportSecuritySettings
5962
from mcp.shared._stream_protocols import ReadStream, WriteStream
6063
from mcp.shared.exceptions import MCPDeprecationWarning
6164
from mcp.shared.message import SessionMessage
6265

6366
if TYPE_CHECKING:
64-
# HTTP transport and auth types appear only in `streamable_http_app`'s
65-
# signature and in narrowed attributes. The runtime imports live inside
66-
# that method, so `import mcp.server` (and every stdio server) never
67-
# loads the HTTP stack: starlette, sse_starlette, uvicorn.
67+
# Starlette types appear only in `streamable_http_app`'s signature; the
68+
# runtime imports live inside that method, so `import mcp.server` (and
69+
# every stdio server) never loads the HTTP stack: starlette,
70+
# sse_starlette, uvicorn. The auth and session-manager annotations are
71+
# spelled through the lazy `mcp.server` namespace instead (below), so
72+
# `typing.get_type_hints` still resolves them on demand at runtime.
6873
from starlette.applications import Starlette
6974
from starlette.routing import Route
7075

71-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
72-
from mcp.server.auth.settings import AuthSettings
73-
from mcp.server.streamable_http import EventStore
74-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
75-
from mcp.server.transport_security import TransportSecuritySettings
76-
7776
logger = logging.getLogger(__name__)
7877

7978
LifespanResultT = TypeVar("LifespanResultT", default=Any)
@@ -425,7 +424,7 @@ def __init__(
425424
self.lifespan = lifespan
426425
self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
427426
self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
428-
self._session_manager: StreamableHTTPSessionManager | None = None
427+
self._session_manager: mcp.server.streamable_http_manager.StreamableHTTPSessionManager | None = None
429428
# Context-tier middleware: wraps every inbound request (including
430429
# `initialize`, lookup, validation, handler) with
431430
# `(ctx, call_next)`. Applied in `ServerRunner._on_request`.
@@ -674,7 +673,7 @@ async def _handle_discover(
674673
)
675674

676675
@property
677-
def session_manager(self) -> StreamableHTTPSessionManager:
676+
def session_manager(self) -> mcp.server.streamable_http_manager.StreamableHTTPSessionManager:
678677
"""Get the StreamableHTTP session manager.
679678
680679
Raises:
@@ -727,9 +726,9 @@ def streamable_http_app(
727726
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
728727
transport_security: TransportSecuritySettings | None = None,
729728
host: str = "127.0.0.1",
730-
auth: AuthSettings | None = None,
731-
token_verifier: TokenVerifier | None = None,
732-
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
729+
auth: mcp.server.auth.settings.AuthSettings | None = None,
730+
token_verifier: mcp.server.auth.provider.TokenVerifier | None = None,
731+
auth_server_provider: mcp.server.auth.provider.OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
733732
custom_starlette_routes: list[Route] | None = None,
734733
debug: bool = False,
735734
) -> Starlette:
@@ -751,7 +750,6 @@ def streamable_http_app(
751750
create_protected_resource_routes,
752751
)
753752
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
754-
from mcp.server.transport_security import TransportSecuritySettings
755753

756754
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
757755
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,16 @@
4747
from pydantic import BaseModel
4848
from pydantic.networks import AnyUrl
4949

50+
import mcp
5051
from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE
51-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
52-
from mcp.server.auth.settings import AuthSettings
52+
53+
# `MCPServer.__init__`'s auth-provider annotations are spelled through the lazy
54+
# `mcp.server.auth` namespace, so this module never imports the OAuth provider
55+
# stack (it loads with the first auth-enabled server instead).
56+
from mcp.server.auth.settings import AuthSettings # light (pydantic-only); the Settings model needs it
5357
from mcp.server.caching import CacheableMethod, CacheHint
5458
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
59+
from mcp.server.event_store import EventStore
5560
from mcp.server.extension import (
5661
Extension,
5762
MethodBinding,
@@ -78,25 +83,24 @@
7883
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
7984
from mcp.server.stdio import stdio_server
8085
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
86+
from mcp.server.transport_security import TransportSecuritySettings
8187
from mcp.shared.exceptions import MCPError
8288
from mcp.shared.uri_template import UriTemplate
8389

8490
if TYPE_CHECKING:
85-
# HTTP transport types appear only in the SSE / streamable-HTTP methods'
91+
# Starlette types appear only in the SSE / streamable-HTTP methods'
8692
# signatures and in narrowed attributes. Their runtime imports live inside
8793
# those methods (and `custom_route`), so `import mcp.server.mcpserver`
8894
# (and every stdio server) never loads starlette, sse_starlette or
89-
# uvicorn - only building an HTTP app pays for the web stack, once.
95+
# uvicorn - only building an HTTP app pays for the web stack, once. The
96+
# session-manager annotation is spelled through the lazy `mcp.server`
97+
# namespace instead, so `typing.get_type_hints` resolves it on demand.
9098
from starlette.applications import Starlette
9199
from starlette.requests import Request
92100
from starlette.responses import Response
93101
from starlette.routing import Route
94102
from starlette.types import Receive, Scope, Send
95103

96-
from mcp.server.streamable_http import EventStore
97-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
98-
from mcp.server.transport_security import TransportSecuritySettings
99-
100104
logger = get_logger(__name__)
101105

102106
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
@@ -158,8 +162,8 @@ def __init__(
158162
website_url: str | None = None,
159163
icons: list[Icon] | None = None,
160164
version: str = "",
161-
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
162-
token_verifier: TokenVerifier | None = None,
165+
auth_server_provider: mcp.server.auth.provider.OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
166+
token_verifier: mcp.server.auth.provider.TokenVerifier | None = None,
163167
*,
164168
tools: list[Tool] | None = None,
165169
resources: list[Resource] | None = None,
@@ -250,6 +254,9 @@ def __init__(
250254

251255
# Create token verifier from provider if needed (backwards compatibility)
252256
if auth_server_provider and not token_verifier:
257+
# The OAuth provider stack loads only for a server configured with one.
258+
from mcp.server.auth.provider import ProviderTokenVerifier
259+
253260
self._token_verifier = ProviderTokenVerifier(auth_server_provider)
254261
self._custom_starlette_routes: list[Route] = []
255262

@@ -301,7 +308,7 @@ def version(self) -> str:
301308
return self._lowlevel_server.version
302309

303310
@property
304-
def session_manager(self) -> StreamableHTTPSessionManager:
311+
def session_manager(self) -> mcp.server.streamable_http_manager.StreamableHTTPSessionManager:
305312
"""Get the StreamableHTTP session manager.
306313
307314
This is exposed to enable advanced use cases like mounting multiple
@@ -1118,7 +1125,6 @@ def sse_app(
11181125
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
11191126
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
11201127
from mcp.server.sse import SseServerTransport
1121-
from mcp.server.transport_security import TransportSecuritySettings
11221128

11231129
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
11241130
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):

‎src/mcp/server/sse.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,9 @@ async def handle_sse(request):
5050
from starlette.responses import Response
5151
from starlette.types import Receive, Scope, Send
5252

53+
from mcp.server._transport_security_middleware import TransportSecurityMiddleware
5354
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
54-
from mcp.server.transport_security import (
55-
TransportSecurityMiddleware,
56-
TransportSecuritySettings,
57-
)
55+
from mcp.server.transport_security import TransportSecuritySettings
5856
from mcp.shared._context_streams import ContextSendStream, create_context_streams
5957
from mcp.shared.message import ServerMessageMetadata, SessionMessage
6058

0 commit comments

Comments
 (0)