Skip to content

Commit fcaf297

Browse files
committed
Make server-side imports pay-for-what-you-use
The HTTP transport stack (starlette, sse_starlette, uvicorn) is no longer imported by the transport-agnostic server modules at module import: the lowlevel Server and MCPServer import the web stack inside streamable_http_app() / sse_app() / custom_route(), with annotation-only names moved under TYPE_CHECKING, so a stdio server never loads it. The request access-token contextvar and get_access_token, which the request-state boundary and handlers read regardless of transport, move to a starlette-free module, mcp.server.auth.access_token, and are re-exported from mcp.server.auth.middleware.auth_context so the existing import path keeps working; the boundary now reads the principal without the HTTP stack. opentelemetry-api is imported on the first span instead of at import (cached in module globals; otel_span takes the span kind by name), and httpx2 is imported only when an HttpResource is read. The streamable-HTTP request-body-size default moves to a leaf module so the servers' signatures do not import the transport.
1 parent 5b25303 commit fcaf297

11 files changed

Lines changed: 220 additions & 79 deletions

File tree

src/mcp/server/_http_defaults.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Streamable HTTP defaults shared by the transport and the servers' signatures.
2+
3+
A leaf module with no third-party imports: `mcp.server.lowlevel` and
4+
`mcp.server.mcpserver` use these as parameter defaults, and importing them from
5+
the HTTP transport modules (which need starlette) would drag the HTTP stack
6+
into every stdio server at import time. `mcp.server.streamable_http_manager`
7+
re-exports the constant under its documented import path.
8+
"""
9+
10+
from typing import Final
11+
12+
DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
13+
"""Default maximum Streamable HTTP request body size in bytes (4 MiB)."""

src/mcp/server/_otel.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
from typing import Any
44

55
from mcp_types import INVALID_PARAMS, CallToolResult
6-
from opentelemetry.trace import SpanKind, StatusCode
76
from pydantic import ValidationError
87

98
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
10-
from mcp.shared._otel import extract_trace_context, otel_span
9+
from mcp.shared._otel import extract_trace_context, otel_span, set_span_error
1110
from mcp.shared.exceptions import MCPError
1211

1312

@@ -34,7 +33,7 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex
3433

3534
with otel_span(
3635
name=f"{ctx.method}{f' {target}' if target else ''}",
37-
kind=SpanKind.SERVER,
36+
kind="server",
3837
attributes=attributes,
3938
context=extract_trace_context(ctx.meta),
4039
record_exception=False,
@@ -45,18 +44,18 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex
4544
except MCPError as e:
4645
code = str(e.error.code)
4746
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
48-
span.set_status(StatusCode.ERROR, e.error.message)
47+
set_span_error(span, e.error.message)
4948
raise
5049
except ValidationError:
5150
# Mirror the sanitized wire response; pydantic messages carry client input.
5251
code = str(INVALID_PARAMS)
5352
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
54-
span.set_status(StatusCode.ERROR, "Invalid request parameters")
53+
set_span_error(span, "Invalid request parameters")
5554
raise
5655
except Exception as e:
5756
span.set_attribute("error.type", type(e).__qualname__)
5857
span.record_exception(e)
59-
span.set_status(StatusCode.ERROR, str(e))
58+
set_span_error(span, str(e))
6059
raise
6160
if ctx.method == "tools/call":
6261
# Tool errors are detected pre-serialization, so only shapes that reach the wire as an error
@@ -66,7 +65,7 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex
6665
match result:
6766
case CallToolResult(is_error=True) | {"isError": True}:
6867
span.set_attribute("error.type", "tool_error")
69-
span.set_status(StatusCode.ERROR)
68+
set_span_error(span)
7069
case _:
7170
pass
7271
return result
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""The access token of the request being served, exposed via a contextvar.
2+
3+
This module is deliberately transport-agnostic: it imports no HTTP framework,
4+
so `mcp.server.request_state` (and any tool handler) can read the caller's
5+
token without loading the web stack. On HTTP transports the contextvar is
6+
populated by `mcp.server.auth.middleware.auth_context.AuthContextMiddleware`,
7+
which also re-exports both names under their long-standing import path.
8+
"""
9+
10+
import contextvars
11+
from typing import TYPE_CHECKING
12+
13+
from mcp.server.auth.provider import AccessToken
14+
15+
if TYPE_CHECKING:
16+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
17+
18+
# Create a contextvar to store the authenticated user
19+
# The default is None, indicating no authenticated user is present
20+
auth_context_var: contextvars.ContextVar["AuthenticatedUser | None"] = contextvars.ContextVar(
21+
"auth_context", default=None
22+
)
23+
24+
25+
def get_access_token() -> AccessToken | None:
26+
"""Get the access token from the current context.
27+
28+
Returns:
29+
The access token if an authenticated user is available, None otherwise.
30+
"""
31+
auth_user = auth_context_var.get()
32+
return auth_user.access_token if auth_user else None

src/mcp/server/auth/middleware/auth_context.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
import contextvars
2-
31
from starlette.types import ASGIApp, Receive, Scope, Send
42

3+
# The contextvar and its accessor are defined in a transport-agnostic module
4+
# (no starlette) so request-state code can read the token without loading the
5+
# web stack; they are re-exported here under their long-standing import path.
6+
from mcp.server.auth.access_token import auth_context_var as auth_context_var
7+
from mcp.server.auth.access_token import get_access_token as get_access_token
58
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
6-
from mcp.server.auth.provider import AccessToken
7-
8-
# Create a contextvar to store the authenticated user
9-
# The default is None, indicating no authenticated user is present
10-
auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None)
11-
12-
13-
def get_access_token() -> AccessToken | None:
14-
"""Get the access token from the current context.
15-
16-
Returns:
17-
The access token if an authenticated user is available, None otherwise.
18-
"""
19-
auth_user = auth_context_var.get()
20-
return auth_user.access_token if auth_user else None
219

2210

2311
class AuthContextMiddleware:

src/mcp/server/lowlevel/server.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,38 +43,37 @@ async def main():
4343
from contextlib import AbstractAsyncContextManager, asynccontextmanager
4444
from dataclasses import dataclass
4545
from functools import cached_property
46-
from typing import Any, Generic, overload
46+
from typing import TYPE_CHECKING, Any, Generic, overload
4747

4848
import mcp_types as types
4949
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
5050
from pydantic import BaseModel
51-
from starlette.applications import Starlette
52-
from starlette.middleware import Middleware
53-
from starlette.middleware.authentication import AuthenticationMiddleware
54-
from starlette.routing import Mount, Route
5551
from typing_extensions import TypeVar, deprecated
5652

53+
from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE
5754
from mcp.server._otel import OpenTelemetryMiddleware
58-
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
59-
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
60-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
61-
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
62-
from mcp.server.auth.settings import AuthSettings
6355
from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints
6456
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
6557
from mcp.server.models import InitializationOptions
6658
from mcp.server.runner import serve_dual_era_loop
67-
from mcp.server.streamable_http import EventStore
68-
from mcp.server.streamable_http_manager import (
69-
DEFAULT_MAX_REQUEST_BODY_SIZE,
70-
StreamableHTTPASGIApp,
71-
StreamableHTTPSessionManager,
72-
)
73-
from mcp.server.transport_security import TransportSecuritySettings
7459
from mcp.shared._stream_protocols import ReadStream, WriteStream
7560
from mcp.shared.exceptions import MCPDeprecationWarning
7661
from mcp.shared.message import SessionMessage
7762

63+
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.
68+
from starlette.applications import Starlette
69+
from starlette.routing import Route
70+
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+
7877
logger = logging.getLogger(__name__)
7978

8079
LifespanResultT = TypeVar("LifespanResultT", default=Any)
@@ -735,6 +734,25 @@ def streamable_http_app(
735734
debug: bool = False,
736735
) -> Starlette:
737736
"""Return an instance of the StreamableHTTP server app."""
737+
# The HTTP transport stack (starlette, plus this SDK's HTTP transport
738+
# and auth ASGI modules) is imported here rather than at module top so
739+
# that `import mcp.server` and stdio servers never load starlette,
740+
# sse_starlette or uvicorn: only building an HTTP app pays for it, once.
741+
from starlette.applications import Starlette
742+
from starlette.middleware import Middleware
743+
from starlette.middleware.authentication import AuthenticationMiddleware
744+
from starlette.routing import Mount, Route
745+
746+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
747+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
748+
from mcp.server.auth.routes import (
749+
build_resource_metadata_url,
750+
create_auth_routes,
751+
create_protected_resource_routes,
752+
)
753+
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
754+
from mcp.server.transport_security import TransportSecuritySettings
755+
738756
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
739757
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
740758
transport_security = TransportSecuritySettings(

src/mcp/server/mcpserver/resources/types.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import anyio
1212
import anyio.to_thread
13-
import httpx2
1413
import pydantic
1514
import pydantic_core
1615
from mcp_types import Annotations, Icon, InputRequiredResult
@@ -197,9 +196,14 @@ class HttpResource(Resource):
197196
url: str = Field(description="URL to fetch content from")
198197
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
199198

200-
async def read(self) -> str | bytes:
199+
async def read(self) -> str | bytes: # pragma: no cover
201200
"""Read the HTTP content."""
202-
async with httpx2.AsyncClient() as client: # pragma: no cover
201+
# httpx2 is imported here rather than at module top: this is the only
202+
# resource type that needs the HTTP client stack, and a server that
203+
# never registers an HttpResource should not pay for it at import time.
204+
import httpx2
205+
206+
async with httpx2.AsyncClient() as client:
203207
response = await client.get(self.url)
204208
response.raise_for_status()
205209
return response.text

src/mcp/server/mcpserver/server.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import inspect
77
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
88
from contextlib import AbstractAsyncContextManager, asynccontextmanager
9-
from typing import Any, Generic, Literal, TypeVar, overload
9+
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, overload
1010

1111
import anyio
1212
import pydantic_core
@@ -46,16 +46,8 @@
4646
from mcp_types import Tool as MCPTool
4747
from pydantic import BaseModel
4848
from pydantic.networks import AnyUrl
49-
from starlette.applications import Starlette
50-
from starlette.middleware import Middleware
51-
from starlette.middleware.authentication import AuthenticationMiddleware
52-
from starlette.requests import Request
53-
from starlette.responses import Response
54-
from starlette.routing import Mount, Route
55-
from starlette.types import Receive, Scope, Send
56-
57-
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
58-
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
49+
50+
from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE
5951
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
6052
from mcp.server.auth.settings import AuthSettings
6153
from mcp.server.caching import CacheableMethod, CacheHint
@@ -84,15 +76,27 @@
8476
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
8577
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
8678
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
87-
from mcp.server.sse import SseServerTransport
8879
from mcp.server.stdio import stdio_server
89-
from mcp.server.streamable_http import EventStore
90-
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
9180
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
92-
from mcp.server.transport_security import TransportSecuritySettings
9381
from mcp.shared.exceptions import MCPError
9482
from mcp.shared.uri_template import UriTemplate
9583

84+
if TYPE_CHECKING:
85+
# HTTP transport types appear only in the SSE / streamable-HTTP methods'
86+
# signatures and in narrowed attributes. Their runtime imports live inside
87+
# those methods (and `custom_route`), so `import mcp.server.mcpserver`
88+
# (and every stdio server) never loads starlette, sse_starlette or
89+
# uvicorn - only building an HTTP app pays for the web stack, once.
90+
from starlette.applications import Starlette
91+
from starlette.requests import Request
92+
from starlette.responses import Response
93+
from starlette.routing import Route
94+
from starlette.types import Receive, Scope, Send
95+
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+
96100
logger = get_logger(__name__)
97101

98102
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
@@ -1005,6 +1009,10 @@ async def health_check(request: Request) -> Response:
10051009
```
10061010
"""
10071011

1012+
# A custom route is an HTTP feature: starlette is imported here rather
1013+
# than at module top so stdio servers never load the HTTP stack.
1014+
from starlette.routing import Route
1015+
10081016
def decorator(
10091017
func: Callable[[Request], Awaitable[Response]],
10101018
) -> Callable[[Request], Awaitable[Response]]:
@@ -1097,6 +1105,21 @@ def sse_app(
10971105
host: str = "127.0.0.1",
10981106
) -> Starlette:
10991107
"""Return an instance of the SSE server app."""
1108+
# The SSE transport stack (starlette, sse_starlette, plus this SDK's SSE
1109+
# transport and auth ASGI modules) is imported here rather than at module
1110+
# top so that stdio servers never load it: only building an SSE app
1111+
# pays for it, once.
1112+
from starlette.applications import Starlette
1113+
from starlette.middleware import Middleware
1114+
from starlette.middleware.authentication import AuthenticationMiddleware
1115+
from starlette.responses import Response
1116+
from starlette.routing import Mount, Route
1117+
1118+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
1119+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
1120+
from mcp.server.sse import SseServerTransport
1121+
from mcp.server.transport_security import TransportSecuritySettings
1122+
11001123
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
11011124
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
11021125
transport_security = TransportSecuritySettings(

src/mcp/server/request_state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS
2727
from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required
2828

29-
from mcp.server.auth.middleware.auth_context import get_access_token
29+
from mcp.server.auth.access_token import get_access_token
3030
from mcp.server.auth.provider import principal_components
3131
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
3232
from mcp.shared.exceptions import MCPError

src/mcp/server/streamable_http_manager.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
from collections import deque
88
from collections.abc import AsyncIterator
9-
from typing import TYPE_CHECKING, Any, Final
9+
from typing import TYPE_CHECKING, Any
1010
from uuid import uuid4
1111

1212
import anyio
@@ -18,6 +18,7 @@
1818
from starlette.responses import Response
1919
from starlette.types import ASGIApp, Message, Receive, Scope, Send
2020

21+
from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE as DEFAULT_MAX_REQUEST_BODY_SIZE
2122
from mcp.server._streamable_http_modern import handle_modern_request
2223
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
2324
from mcp.server.connection import Connection
@@ -34,9 +35,6 @@
3435

3536
logger = logging.getLogger(__name__)
3637

37-
DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
38-
"""Default maximum Streamable HTTP request body size in bytes (4 MiB)."""
39-
4038

4139
class StreamableHTTPSessionManager:
4240
"""Manages StreamableHTTP sessions with optional resumability via event store.

0 commit comments

Comments
 (0)