Skip to content

Commit 9ef39b6

Browse files
committed
Load the OAuth provider stack and cryptography with their first user, not the server
Two dependencies still rode along with every `import mcp.server*` for features most servers never use: the OAuth provider models (via mcp.shared.auth and urllib.parse, ~10 ms) were imported by the access-token leaf only for a return annotation and by request_state for principal decomposition, and cryptography (~10 ms, 20-odd modules) was imported at request_state's module top for the built-in codec. The access-token module now spells its return type through the lazy mcp.server.auth namespace and imports no OAuth model; request_state resolves the provider's principal_components and the AEAD/KDF primitives through cached loaders on first use (a codec is built by MCPServer(), so that is where cryptography now loads - once, at construction, never per request). The import-cost ratchet adds cryptography to the banned set of every transport-agnostic server entry point and pins where its deferred load lands.
1 parent cdc78fc commit 9ef39b6

3 files changed

Lines changed: 85 additions & 20 deletions

File tree

‎src/mcp/server/auth/access_token.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
11
"""The access token of the request being served, exposed via a contextvar.
22
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.
3+
This module is deliberately transport- and auth-stack-agnostic: it imports no
4+
HTTP framework and no OAuth model, so `mcp.server.request_state` (and any tool
5+
handler) can read the caller's token without loading either. On HTTP transports
6+
the contextvar is populated by
7+
`mcp.server.auth.middleware.auth_context.AuthContextMiddleware`, which also
8+
re-exports both names under their long-standing import path.
89
"""
910

11+
from __future__ import annotations
12+
1013
import contextvars
1114
from typing import TYPE_CHECKING
1215

13-
from mcp.server.auth.provider import AccessToken
16+
import mcp
1417

1518
if TYPE_CHECKING:
1619
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
1720

1821
# Create a contextvar to store the authenticated user
1922
# The default is None, indicating no authenticated user is present
20-
auth_context_var: contextvars.ContextVar["AuthenticatedUser | None"] = contextvars.ContextVar(
23+
auth_context_var: contextvars.ContextVar[AuthenticatedUser | None] = contextvars.ContextVar(
2124
"auth_context", default=None
2225
)
2326

2427

25-
def get_access_token() -> AccessToken | None:
28+
# The return type is spelled through the lazy `mcp.server` namespace so this
29+
# module never imports the OAuth provider models, while
30+
# `typing.get_type_hints(get_access_token)` still resolves it on demand.
31+
def get_access_token() -> mcp.server.auth.provider.AccessToken | None:
2632
"""Get the access token from the current context.
2733
2834
Returns:

‎src/mcp/server/request_state.py‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,21 @@
1717
import time
1818
from collections.abc import Callable, Mapping, Sequence
1919
from dataclasses import replace
20-
from typing import Any, NoReturn, Protocol, cast
20+
from functools import cache
21+
from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, Protocol, cast
2122

22-
from cryptography.exceptions import InvalidTag
23-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
24-
from cryptography.hazmat.primitives.hashes import SHA256
25-
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
2623
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS
2724
from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required
2825

2926
from mcp.server.auth.access_token import get_access_token
30-
from mcp.server.auth.provider import principal_components
3127
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
3228
from mcp.shared.exceptions import MCPError
3329

30+
if TYPE_CHECKING:
31+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
32+
33+
from mcp.server.auth.provider import AccessToken
34+
3435
__all__ = [
3536
"AESGCMRequestStateCodec",
3637
"InvalidRequestState",
@@ -88,7 +89,7 @@ def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None:
8889
token = get_access_token()
8990
if token is None:
9091
return None
91-
return compact_json(principal_components(token))
92+
return compact_json(_principal_components()(token))
9293

9394

9495
class RequestStateSecurity:
@@ -179,9 +180,47 @@ def _b64u_decode(text: str) -> bytes:
179180
return raw
180181

181182

183+
class _Crypto(NamedTuple):
184+
"""The `cryptography` primitives the built-in codec uses, imported together."""
185+
186+
aesgcm: type[AESGCM]
187+
hkdf: Any
188+
sha256: Any
189+
invalid_tag: type[BaseException]
190+
191+
192+
@cache
193+
def _crypto() -> _Crypto:
194+
"""Import (once) the AEAD/KDF primitives from `cryptography`.
195+
196+
Kept off `import mcp.server*`: only a server that seals `requestState` with the
197+
built-in codec needs them, so the ~20 modules load with the first codec
198+
construction instead of with every server import.
199+
"""
200+
from cryptography.exceptions import InvalidTag
201+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
202+
from cryptography.hazmat.primitives.hashes import SHA256
203+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
204+
205+
return _Crypto(AESGCM, HKDF, SHA256, InvalidTag)
206+
207+
208+
@cache
209+
def _principal_components() -> Callable[[AccessToken], tuple[str, str | None, str | None]]:
210+
"""Import (once) the auth stack's principal decomposition.
211+
212+
Kept off `import mcp.server*`: only an authenticated transport reaches it,
213+
on the first request that binds request state to a principal.
214+
"""
215+
from mcp.server.auth.provider import principal_components
216+
217+
return principal_components
218+
219+
182220
def _derive_key(secret: bytes) -> bytes:
183221
"""Stretch an operator secret (>= 32 bytes, any format) into the AES-256 key."""
184-
return HKDF(algorithm=SHA256(), length=32, salt=None, info=_KDF_INFO).derive(secret)
222+
crypto = _crypto()
223+
return crypto.hkdf(algorithm=crypto.sha256(), length=32, salt=None, info=_KDF_INFO).derive(secret)
185224

186225

187226
class AESGCMRequestStateCodec:
@@ -212,14 +251,15 @@ def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None:
212251
f"keys[{i}] is {len(k)} bytes. "
213252
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
214253
)
254+
crypto = self._crypto = _crypto()
215255
self._ring: dict[bytes, AESGCM] = {}
216256
self._mint_kid = b""
217257
for i, secret in enumerate(material):
218258
key = _derive_key(secret)
219259
kid = hashlib.sha256(_KID_INFO + key).digest()[:_KID_LEN]
220260
if kid in self._ring:
221261
raise ValueError(f"keys[{i}] duplicates an earlier ring key")
222-
self._ring[kid] = AESGCM(key)
262+
self._ring[kid] = crypto.aesgcm(key)
223263
if i == 0:
224264
self._mint_kid = kid
225265

@@ -244,7 +284,7 @@ def unseal(self, token: str) -> bytes:
244284
raise InvalidRequestState("unknown key")
245285
try:
246286
return aead.decrypt(nonce, sealed, _TOKEN_PREFIX.encode() + kid)
247-
except InvalidTag:
287+
except self._crypto.invalid_tag:
248288
raise InvalidRequestState("seal") from None
249289

250290

‎tests/test_import_guards.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@
4545
# stack, and the auth/telemetry dependencies that only server-side code paths use.
4646
CLIENT_BANNED = ("mcp.server", *HTTP_SERVER_STACK, "opentelemetry", "cryptography", "jwt")
4747

48-
# Everything a transport-agnostic server entry point must never load.
49-
SERVER_BANNED = (*HTTP_SERVER_STACK, "opentelemetry", "httpx2", "jwt")
48+
# Everything a transport-agnostic server entry point must never load. `cryptography` backs
49+
# the built-in request-state codec and loads with the first codec construction (`MCPServer()`
50+
# builds one), never at import.
51+
SERVER_BANNED = (*HTTP_SERVER_STACK, "opentelemetry", "httpx2", "jwt", "cryptography")
5052

5153
# Per-version wire-schema packages: loaded only when that protocol version is used.
5254
WIRE_PACKAGES = ("mcp_types._v2025_11_25", "mcp_types._v2026_07_28")
@@ -146,6 +148,23 @@ def test_first_url_client_is_what_loads_the_http_client_transport():
146148
assert _hits(after, CLIENT_BANNED) == []
147149

148150

151+
def test_request_state_codec_is_what_loads_cryptography():
152+
"""The deferred load happens where it is used: importing the server does not load
153+
`cryptography`; constructing an `MCPServer` (which builds the default request-state
154+
codec) is what does."""
155+
probe = (
156+
"import json, sys\n"
157+
"from mcp.server.mcpserver import MCPServer\n"
158+
"before = sorted(sys.modules)\n"
159+
"MCPServer('probe')\n"
160+
"print(json.dumps([before, sorted(sys.modules)]))\n"
161+
)
162+
before, after = (set(names) for names in json.loads(_run(probe)))
163+
assert _hits(before, SERVER_BANNED) == []
164+
assert _hits(after, ("cryptography",)) == ["cryptography"]
165+
assert _hits(after, HTTP_SERVER_STACK) == []
166+
167+
149168
@pytest.mark.parametrize("module_name", _documented_modules())
150169
def test_documented_module_imports_first_in_a_fresh_interpreter(module_name: str):
151170
"""Every documented module imports cleanly and warning-free as a process's first

0 commit comments

Comments
 (0)