Skip to content

Commit db58af3

Browse files
committed
Add dispatch-level tests for auth context from the request scope (#1250)
1 parent 1631a8f commit db58af3

1 file changed

Lines changed: 133 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""The lowlevel server applies each request's authenticated user while handling it.
2+
3+
Regression coverage for issue #1250 at the dispatch layer: handlers run in
4+
the session's long-lived task, so the `auth_context_var` set by
5+
`AuthContextMiddleware` on the HTTP request's task would otherwise still hold
6+
the session-opening request's user. `_handle_request` re-applies the user
7+
from the request the transport attached to the message metadata.
8+
"""
9+
10+
from typing import Any
11+
from unittest.mock import AsyncMock, Mock
12+
13+
import pytest
14+
from starlette.requests import Request
15+
from starlette.types import Scope
16+
17+
import mcp.types as types
18+
from mcp.server.auth.middleware.auth_context import auth_context_var, get_access_token
19+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
20+
from mcp.server.auth.provider import AccessToken
21+
from mcp.server.lowlevel.server import Server
22+
from mcp.server.session import ServerSession
23+
from mcp.shared.message import ServerMessageMetadata
24+
from mcp.shared.session import RequestResponder
25+
26+
27+
def make_server(observed: list[str | None]) -> Server:
28+
server = Server("test-server")
29+
30+
@server.list_tools()
31+
async def handle_list_tools() -> list[types.Tool]:
32+
return [types.Tool(name="echo_token", description="echo", inputSchema={"type": "object"})]
33+
34+
@server.call_tool()
35+
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
36+
assert name == "echo_token"
37+
token = get_access_token()
38+
observed.append(token.token if token else None)
39+
return [types.TextContent(type="text", text=token.token if token else "no-token")]
40+
41+
return server
42+
43+
44+
async def dispatch(server: Server, metadata: ServerMessageMetadata | None) -> Mock:
45+
message = Mock(spec=RequestResponder)
46+
message.request_id = "req-1"
47+
message.request_meta = None
48+
message.message_metadata = metadata
49+
message.respond = AsyncMock()
50+
51+
session = Mock(spec=ServerSession)
52+
session.client_params = None
53+
54+
request = types.CallToolRequest(params=types.CallToolRequestParams(name="echo_token", arguments={}))
55+
await server._handle_request(message, request, session, {}, raise_exceptions=False)
56+
return message
57+
58+
59+
def request_with_scope(scope: Scope) -> Request:
60+
return Request(scope)
61+
62+
63+
@pytest.mark.anyio
64+
async def test_handle_request_applies_the_user_from_the_request_scope() -> None:
65+
"""A handler sees the user authenticated for its own request, and the var is reset after."""
66+
observed: list[str | None] = []
67+
server = make_server(observed)
68+
69+
verified = AccessToken(token="scope" + "-token", client_id="test-client", scopes=["test"])
70+
scope: Scope = {
71+
"type": "http",
72+
"method": "POST",
73+
"path": "/mcp",
74+
"headers": [],
75+
"user": AuthenticatedUser(verified),
76+
}
77+
message = await dispatch(server, ServerMessageMetadata(request_context=request_with_scope(scope)))
78+
79+
assert observed == ["scope-token"]
80+
assert auth_context_var.get() is None
81+
response = message.respond.call_args.args[0]
82+
assert isinstance(response.root, types.CallToolResult)
83+
content = response.root.content[0]
84+
assert isinstance(content, types.TextContent)
85+
assert content.text == "scope-token"
86+
87+
88+
@pytest.mark.anyio
89+
async def test_handle_request_without_a_user_in_scope_leaves_the_context_empty() -> None:
90+
"""Requests whose scope carries no authenticated user run unauthenticated."""
91+
observed: list[str | None] = []
92+
server = make_server(observed)
93+
94+
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
95+
await dispatch(server, ServerMessageMetadata(request_context=request_with_scope(scope)))
96+
97+
assert observed == [None]
98+
assert auth_context_var.get() is None
99+
100+
101+
@pytest.mark.anyio
102+
async def test_handle_request_with_a_non_authenticated_user_in_scope_leaves_the_context_empty() -> None:
103+
"""A scope user that is not an `AuthenticatedUser` (e.g. a plain Starlette user) is ignored."""
104+
observed: list[str | None] = []
105+
server = make_server(observed)
106+
107+
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": [], "user": object()}
108+
await dispatch(server, ServerMessageMetadata(request_context=request_with_scope(scope)))
109+
110+
assert observed == [None]
111+
112+
113+
@pytest.mark.anyio
114+
async def test_handle_request_with_an_attached_object_without_a_scope_leaves_the_context_empty() -> None:
115+
"""A request context that is not a Starlette request has no scope to read a user from."""
116+
observed: list[str | None] = []
117+
server = make_server(observed)
118+
119+
await dispatch(server, ServerMessageMetadata(request_context=object()))
120+
121+
assert observed == [None]
122+
123+
124+
@pytest.mark.anyio
125+
async def test_handle_request_without_metadata_leaves_the_context_empty() -> None:
126+
"""Transports that attach no request (e.g. stdio) run unauthenticated, as before."""
127+
observed: list[str | None] = []
128+
server = make_server(observed)
129+
130+
await dispatch(server, None)
131+
132+
assert observed == [None]
133+
assert auth_context_var.get() is None

0 commit comments

Comments
 (0)