Skip to content

Commit 117f0da

Browse files
committed
refactor(auth): decouple core server from auth module for tenant extraction
Move tenant identification to a transport-agnostic contextvar (tenant_id_var) in the shared layer, removing the hard dependency from lowlevel/server.py on the auth middleware module. AuthContextMiddleware now sets tenant_id_var alongside auth_context_var, and the core server reads from the shared contextvar instead of calling get_tenant_id() from the auth module. This keeps the dependency direction correct (auth → shared, server → shared) and allows other transports to set tenant_id_var through their own mechanisms.
1 parent 3dfb2d7 commit 117f0da

5 files changed

Lines changed: 97 additions & 18 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
66
from mcp.server.auth.provider import AccessToken
7+
from mcp.shared._context import tenant_id_var
78

89
# Create a contextvar to store the authenticated user
910
# The default is None, indicating no authenticated user is present
@@ -46,11 +47,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send):
4647
user = scope.get("user")
4748
if isinstance(user, AuthenticatedUser):
4849
# Set the authenticated user in the contextvar
49-
token = auth_context_var.set(user)
50+
auth_token = auth_context_var.set(user)
51+
# Propagate tenant_id to the transport-agnostic contextvar
52+
tenant_id = user.access_token.tenant_id if user.access_token else None
53+
tenant_token = tenant_id_var.set(tenant_id)
5054
try:
5155
await self.app(scope, receive, send)
5256
finally:
53-
auth_context_var.reset(token)
57+
tenant_id_var.reset(tenant_token)
58+
auth_context_var.reset(auth_token)
5459
else:
5560
# No authenticated user, just process the request
5661
await self.app(scope, receive, send)

src/mcp/server/lowlevel/server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async def main():
5252
from typing_extensions import TypeVar
5353

5454
from mcp import types
55-
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware, get_tenant_id
55+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
5656
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
5757
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
5858
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
@@ -65,6 +65,7 @@ async def main():
6565
from mcp.server.streamable_http import EventStore
6666
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
6767
from mcp.server.transport_security import TransportSecuritySettings
68+
from mcp.shared._context import tenant_id_var
6869
from mcp.shared.exceptions import MCPError
6970
from mcp.shared.message import ServerMessageMetadata, SessionMessage
7071
from mcp.shared.session import RequestResponder
@@ -451,7 +452,7 @@ async def _handle_request(
451452
task_metadata = None
452453
if hasattr(req, "params") and req.params is not None:
453454
task_metadata = getattr(req.params, "task", None)
454-
tenant_id = get_tenant_id()
455+
tenant_id = tenant_id_var.get()
455456
if tenant_id is not None and session.tenant_id is None:
456457
session.tenant_id = tenant_id
457458
ctx = ServerRequestContext(
@@ -499,7 +500,7 @@ async def _handle_notification(
499500
try:
500501
client_capabilities = session.client_params.capabilities if session.client_params else None
501502
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
502-
tenant_id = get_tenant_id()
503+
tenant_id = tenant_id_var.get()
503504
if tenant_id is not None and session.tenant_id is None:
504505
session.tenant_id = tenant_id
505506
ctx = ServerRequestContext(

src/mcp/shared/_context.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Request context for MCP handlers."""
22

3+
import contextvars
34
from dataclasses import dataclass
45
from typing import Any, Generic
56

@@ -8,6 +9,11 @@
89
from mcp.shared.session import BaseSession
910
from mcp.types import RequestId, RequestParamsMeta
1011

12+
# Transport-agnostic contextvar for tenant identification.
13+
# Set by the transport layer (e.g., AuthContextMiddleware for HTTP+OAuth).
14+
# Read by the core server to populate RequestContext.tenant_id.
15+
tenant_id_var = contextvars.ContextVar[str | None]("tenant_id", default=None)
16+
1117
SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any])
1218

1319

tests/server/auth/middleware/test_auth_context.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
1515
from mcp.server.auth.provider import AccessToken
16+
from mcp.shared._context import tenant_id_var
1617

1718

1819
class MockApp:
@@ -169,6 +170,60 @@ async def send(message: Message) -> None: # pragma: no cover
169170
assert get_tenant_id() is None
170171

171172

173+
@pytest.mark.anyio
174+
async def test_middleware_sets_tenant_id_var(access_token_with_tenant: AccessToken):
175+
"""Test AuthContextMiddleware populates the transport-agnostic tenant_id_var."""
176+
user = AuthenticatedUser(access_token_with_tenant)
177+
scope: Scope = {"type": "http", "user": user}
178+
179+
observed_tenant_id: str | None = None
180+
181+
class CheckApp:
182+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
183+
nonlocal observed_tenant_id
184+
observed_tenant_id = tenant_id_var.get()
185+
186+
middleware = AuthContextMiddleware(CheckApp())
187+
188+
async def receive() -> Message: # pragma: no cover
189+
return {"type": "http.request"}
190+
191+
async def send(message: Message) -> None: # pragma: no cover
192+
pass
193+
194+
await middleware(scope, receive, send)
195+
196+
assert observed_tenant_id == "tenant-abc"
197+
# Verify contextvar is reset after middleware
198+
assert tenant_id_var.get() is None
199+
200+
201+
@pytest.mark.anyio
202+
async def test_middleware_sets_tenant_id_var_none_without_tenant(valid_access_token: AccessToken):
203+
"""Test AuthContextMiddleware sets tenant_id_var to None when token has no tenant."""
204+
user = AuthenticatedUser(valid_access_token)
205+
scope: Scope = {"type": "http", "user": user}
206+
207+
observed_tenant_id: str | None = "sentinel"
208+
209+
class CheckApp:
210+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
211+
nonlocal observed_tenant_id
212+
observed_tenant_id = tenant_id_var.get()
213+
214+
middleware = AuthContextMiddleware(CheckApp())
215+
216+
async def receive() -> Message: # pragma: no cover
217+
return {"type": "http.request"}
218+
219+
async def send(message: Message) -> None: # pragma: no cover
220+
pass
221+
222+
await middleware(scope, receive, send)
223+
224+
assert observed_tenant_id is None
225+
226+
172227
@pytest.mark.anyio
173228
async def test_get_tenant_id_without_tenant(valid_access_token: AccessToken):
174229
"""Test get_tenant_id returns None when auth context has no tenant."""

tests/server/test_multi_tenancy_session.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414
from mcp.server.experimental.request_context import Experimental
1515
from mcp.server.models import InitializationOptions
1616
from mcp.server.session import ServerSession
17-
from mcp.shared._context import RequestContext
17+
from mcp.shared._context import RequestContext, tenant_id_var
1818
from mcp.shared.message import SessionMessage
1919
from mcp.shared.session import BaseSession
2020
from mcp.types import ListToolsResult, NotificationParams, PaginatedRequestParams, ServerCapabilities
2121

2222

2323
def _simulate_tenant_binding(session: ServerSession, tenant_id_value: str) -> None:
24-
"""Simulate the set-once tenant binding logic from lowlevel/server.py."""
24+
"""Simulate the set-once tenant binding logic from lowlevel/server.py.
25+
26+
Sets both auth_context_var (as AuthContextMiddleware does) and tenant_id_var
27+
(the transport-agnostic contextvar that the server reads).
28+
"""
2529
access_token = AccessToken(
2630
token=f"token-{tenant_id_value}",
2731
client_id="client",
@@ -30,13 +34,15 @@ def _simulate_tenant_binding(session: ServerSession, tenant_id_value: str) -> No
3034
tenant_id=tenant_id_value,
3135
)
3236
user = AuthenticatedUser(access_token)
33-
context_token = auth_context_var.set(user)
37+
auth_token = auth_context_var.set(user)
38+
tenant_token = tenant_id_var.set(tenant_id_value)
3439
try:
35-
tenant_id = get_tenant_id()
40+
tenant_id = tenant_id_var.get()
3641
if tenant_id is not None and session.tenant_id is None:
3742
session.tenant_id = tenant_id
3843
finally:
39-
auth_context_var.reset(context_token)
44+
tenant_id_var.reset(tenant_token)
45+
auth_context_var.reset(auth_token)
4046

4147

4248
@pytest.fixture
@@ -269,18 +275,20 @@ async def simulate_request(tenant_id: str, request_key: str) -> None:
269275
)
270276
user = AuthenticatedUser(access_token)
271277

272-
# Set the auth context - this is what AuthContextMiddleware does
273-
context_token = auth_context_var.set(user)
278+
# Set both contextvars - this is what AuthContextMiddleware does
279+
auth_token = auth_context_var.set(user)
280+
tenant_token = tenant_id_var.set(tenant_id)
274281
try:
275282
# Yield control to allow other tasks to run. This is the critical
276283
# point where context leakage could occur if isolation is broken.
277284
await anyio.sleep(0.01)
278285

279286
# Read back the tenant_id - should still be our tenant, not the other
280-
results[request_key] = get_tenant_id()
287+
results[request_key] = tenant_id_var.get()
281288
finally:
282289
# Always reset the context (mirrors middleware behavior)
283-
auth_context_var.reset(context_token)
290+
tenant_id_var.reset(tenant_token)
291+
auth_context_var.reset(auth_token)
284292

285293
# Run both requests concurrently using a task group
286294
async with anyio.create_task_group() as tg:
@@ -362,12 +370,14 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP
362370
tenant_id="tenant-e2e",
363371
)
364372
user = AuthenticatedUser(access_token)
365-
token = auth_context_var.set(user)
373+
auth_token = auth_context_var.set(user)
374+
tenant_token = tenant_id_var.set("tenant-e2e")
366375
try:
367376
async with Client(server) as client:
368377
await client.list_tools()
369378
finally:
370-
auth_context_var.reset(token)
379+
tenant_id_var.reset(tenant_token)
380+
auth_context_var.reset(auth_token)
371381

372382
assert captured_ctx_tenant == "tenant-e2e"
373383
assert captured_session_tenant == "tenant-e2e"
@@ -399,13 +409,15 @@ async def handle_roots_list_changed(ctx: ServerRequestContext, params: Notificat
399409
tenant_id="tenant-notify",
400410
)
401411
user = AuthenticatedUser(access_token)
402-
token = auth_context_var.set(user)
412+
auth_token = auth_context_var.set(user)
413+
tenant_token = tenant_id_var.set("tenant-notify")
403414
try:
404415
async with Client(server) as client:
405416
await client.session.send_roots_list_changed()
406417
with anyio.fail_after(5):
407418
await notification_received.wait()
408419
finally:
409-
auth_context_var.reset(token)
420+
tenant_id_var.reset(tenant_token)
421+
auth_context_var.reset(auth_token)
410422

411423
assert notification_tenant == "tenant-notify"

0 commit comments

Comments
 (0)