Skip to content

Commit 0c36ac5

Browse files
authored
Merge pull request #3 from andylim-duo/feature/multi-tenant-session-context
feat(auth): add tenant_id to session and request context
2 parents e3d0b7b + 92dff29 commit 0c36ac5

7 files changed

Lines changed: 612 additions & 3 deletions

File tree

src/mcp/client/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
407407

408408
return result
409409

410-
async def send_roots_list_changed(self) -> None: # pragma: no cover
410+
async def send_roots_list_changed(self) -> None:
411411
"""Send a roots/list_changed notification."""
412412
await self.send_notification(types.RootsListChangedNotification())
413413

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

Lines changed: 17 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
@@ -20,6 +21,16 @@ def get_access_token() -> AccessToken | None:
2021
return auth_user.access_token if auth_user else None
2122

2223

24+
def get_tenant_id() -> str | None:
25+
"""Get the tenant_id from the current authentication context.
26+
27+
Returns:
28+
The tenant_id if an authenticated user with a tenant is available, None otherwise.
29+
"""
30+
access_token = get_access_token()
31+
return access_token.tenant_id if access_token else None
32+
33+
2334
class AuthContextMiddleware:
2435
"""Middleware that extracts the authenticated user from the request
2536
and sets it in a contextvar for easy access throughout the request lifecycle.
@@ -36,11 +47,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send):
3647
user = scope.get("user")
3748
if isinstance(user, AuthenticatedUser):
3849
# Set the authenticated user in the contextvar
39-
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)
4054
try:
4155
await self.app(scope, receive, send)
4256
finally:
43-
auth_context_var.reset(token)
57+
tenant_id_var.reset(tenant_token)
58+
auth_context_var.reset(auth_token)
4459
else:
4560
# No authenticated user, just process the request
4661
await self.app(scope, receive, send)

src/mcp/server/lowlevel/server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,11 +452,15 @@ 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)
455+
tenant_id = tenant_id_var.get()
456+
if tenant_id is not None and session.tenant_id is None:
457+
session.tenant_id = tenant_id
454458
ctx = ServerRequestContext(
455459
request_id=message.request_id,
456460
meta=message.request_meta,
457461
session=session,
458462
lifespan_context=lifespan_context,
463+
tenant_id=tenant_id,
459464
experimental=Experimental(
460465
task_metadata=task_metadata,
461466
_client_capabilities=client_capabilities,
@@ -495,9 +500,13 @@ async def _handle_notification(
495500
try:
496501
client_capabilities = session.client_params.capabilities if session.client_params else None
497502
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
503+
tenant_id = tenant_id_var.get()
504+
if tenant_id is not None and session.tenant_id is None:
505+
session.tenant_id = tenant_id
498506
ctx = ServerRequestContext(
499507
session=session,
500508
lifespan_context=lifespan_context,
509+
tenant_id=tenant_id,
501510
experimental=Experimental(
502511
task_metadata=None,
503512
_client_capabilities=client_capabilities,

src/mcp/server/session.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class ServerSession(
7676
_initialized: InitializationState = InitializationState.NotInitialized
7777
_client_params: types.InitializeRequestParams | None = None
7878
_experimental_features: ExperimentalServerSessionFeatures | None = None
79+
_tenant_id: str | None = None
7980

8081
def __init__(
8182
self,
@@ -108,6 +109,27 @@ def _receive_notification_adapter(self) -> TypeAdapter[types.ClientNotification]
108109
def client_params(self) -> types.InitializeRequestParams | None:
109110
return self._client_params
110111

112+
@property
113+
def tenant_id(self) -> str | None:
114+
"""Get the tenant_id for this session."""
115+
return self._tenant_id
116+
117+
@tenant_id.setter
118+
def tenant_id(self, value: str | None) -> None:
119+
"""Set the tenant_id for this session (set-once).
120+
121+
Once a session is bound to a tenant, the tenant_id cannot be changed.
122+
This prevents accidental tenant reassignment which could be a security issue.
123+
124+
Raises:
125+
ValueError: If tenant_id is already set to a different value.
126+
"""
127+
if self._tenant_id is not None and value != self._tenant_id:
128+
raise ValueError(
129+
f"Cannot change tenant_id from '{self._tenant_id}' to '{value}': session is already bound to a tenant"
130+
)
131+
self._tenant_id = value
132+
111133
@property
112134
def experimental(self) -> ExperimentalServerSessionFeatures:
113135
"""Experimental APIs for server→client task operations.

src/mcp/shared/_context.py

Lines changed: 11 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

@@ -17,8 +23,13 @@ class RequestContext(Generic[SessionT]):
1723
1824
For request handlers, request_id is always populated.
1925
For notification handlers, request_id is None.
26+
27+
The tenant_id field is used in multi-tenant server deployments to identify
28+
which tenant the request belongs to. It is populated from session context
29+
and enables tenant-specific request handling and isolation.
2030
"""
2131

2232
session: SessionT
2333
request_id: RequestId | None = None
2434
meta: RequestParamsMeta | None = None
35+
tenant_id: str | None = None

tests/server/auth/middleware/test_auth_context.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
AuthContextMiddleware,
1010
auth_context_var,
1111
get_access_token,
12+
get_tenant_id,
1213
)
1314
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
1415
from mcp.server.auth.provider import AccessToken
16+
from mcp.shared._context import tenant_id_var
1517

1618

1719
class MockApp:
@@ -117,3 +119,129 @@ async def send(message: Message) -> None: # pragma: no cover
117119
# Verify context is still empty after middleware
118120
assert auth_context_var.get() is None
119121
assert get_access_token() is None
122+
123+
124+
@pytest.fixture
125+
def access_token_with_tenant() -> AccessToken:
126+
"""Create an access token with a tenant_id."""
127+
return AccessToken(
128+
token="tenant_token",
129+
client_id="test_client",
130+
scopes=["read", "write"],
131+
expires_at=int(time.time()) + 3600,
132+
tenant_id="tenant-abc",
133+
)
134+
135+
136+
def test_get_tenant_id_without_auth_context():
137+
"""Test get_tenant_id returns None when no auth context exists."""
138+
assert auth_context_var.get() is None
139+
assert get_tenant_id() is None
140+
141+
142+
@pytest.mark.anyio
143+
async def test_get_tenant_id_with_tenant(access_token_with_tenant: AccessToken):
144+
"""Test get_tenant_id returns tenant_id when auth context has a tenant."""
145+
user = AuthenticatedUser(access_token_with_tenant)
146+
scope: Scope = {"type": "http", "user": user}
147+
148+
tenant_id_during_call: str | None = None
149+
150+
class TenantCheckApp:
151+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
152+
nonlocal tenant_id_during_call
153+
tenant_id_during_call = get_tenant_id()
154+
155+
middleware = AuthContextMiddleware(TenantCheckApp())
156+
157+
async def receive() -> Message: # pragma: no cover
158+
return {"type": "http.request"}
159+
160+
async def send(message: Message) -> None: # pragma: no cover
161+
pass
162+
163+
await middleware(scope, receive, send)
164+
165+
assert tenant_id_during_call == "tenant-abc"
166+
# Verify context is reset after middleware
167+
assert get_tenant_id() is None
168+
169+
170+
@pytest.mark.anyio
171+
async def test_middleware_sets_tenant_id_var(access_token_with_tenant: AccessToken):
172+
"""Test AuthContextMiddleware populates the transport-agnostic tenant_id_var."""
173+
user = AuthenticatedUser(access_token_with_tenant)
174+
scope: Scope = {"type": "http", "user": user}
175+
176+
observed_tenant_id: str | None = None
177+
178+
class CheckApp:
179+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
180+
nonlocal observed_tenant_id
181+
observed_tenant_id = tenant_id_var.get()
182+
183+
middleware = AuthContextMiddleware(CheckApp())
184+
185+
async def receive() -> Message: # pragma: no cover
186+
return {"type": "http.request"}
187+
188+
async def send(message: Message) -> None: # pragma: no cover
189+
pass
190+
191+
await middleware(scope, receive, send)
192+
193+
assert observed_tenant_id == "tenant-abc"
194+
# Verify contextvar is reset after middleware
195+
assert tenant_id_var.get() is None
196+
197+
198+
@pytest.mark.anyio
199+
async def test_middleware_sets_tenant_id_var_none_without_tenant(valid_access_token: AccessToken):
200+
"""Test AuthContextMiddleware sets tenant_id_var to None when token has no tenant."""
201+
user = AuthenticatedUser(valid_access_token)
202+
scope: Scope = {"type": "http", "user": user}
203+
204+
observed_tenant_id: str | None = "sentinel"
205+
206+
class CheckApp:
207+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
208+
nonlocal observed_tenant_id
209+
observed_tenant_id = tenant_id_var.get()
210+
211+
middleware = AuthContextMiddleware(CheckApp())
212+
213+
async def receive() -> Message: # pragma: no cover
214+
return {"type": "http.request"}
215+
216+
async def send(message: Message) -> None: # pragma: no cover
217+
pass
218+
219+
await middleware(scope, receive, send)
220+
221+
assert observed_tenant_id is None
222+
223+
224+
@pytest.mark.anyio
225+
async def test_get_tenant_id_without_tenant(valid_access_token: AccessToken):
226+
"""Test get_tenant_id returns None when auth context has no tenant."""
227+
tenant_id_during_call: str | None = "not-none"
228+
229+
class TenantCheckApp:
230+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
231+
nonlocal tenant_id_during_call
232+
tenant_id_during_call = get_tenant_id()
233+
234+
middleware = AuthContextMiddleware(TenantCheckApp())
235+
236+
user = AuthenticatedUser(valid_access_token)
237+
scope: Scope = {"type": "http", "user": user}
238+
239+
async def receive() -> Message: # pragma: no cover
240+
return {"type": "http.request"}
241+
242+
async def send(message: Message) -> None: # pragma: no cover
243+
pass
244+
245+
await middleware(scope, receive, send)
246+
247+
assert tenant_id_during_call is None

0 commit comments

Comments
 (0)