Skip to content

Commit cc22bf5

Browse files
maxisbeyKludex
andauthored
refactor: remove request_ctx ContextVar, thread Context explicitly (#2203)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
1 parent 62575ed commit cc22bf5

23 files changed

+484
-413
lines changed

docs/migration.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,37 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru
288288

289289
**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor.
290290

291+
### `MCPServer.get_context()` removed
292+
293+
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
294+
295+
**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead.
296+
297+
**Before (v1):**
298+
299+
```python
300+
@mcp.tool()
301+
async def my_tool(x: int) -> str:
302+
ctx = mcp.get_context()
303+
await ctx.info("Processing...")
304+
return str(x)
305+
```
306+
307+
**After (v2):**
308+
309+
```python
310+
@mcp.tool()
311+
async def my_tool(x: int, ctx: Context) -> str:
312+
await ctx.info("Processing...")
313+
return str(x)
314+
```
315+
316+
### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter
317+
318+
`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise.
319+
320+
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
321+
291322
### Replace `RootModel` by union types with `TypeAdapter` validation
292323

293324
The following union types are no longer `RootModel` subclasses:
@@ -694,7 +725,7 @@ If you prefer the convenience of automatic wrapping, use `MCPServer` which still
694725

695726
### Lowlevel `Server`: `request_context` property removed
696727

697-
The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar is now an internal implementation detail and should not be relied upon.
728+
The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely.
698729

699730
**Before (v1):**
700731

src/mcp/server/lowlevel/server.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ async def main():
3636

3737
from __future__ import annotations
3838

39-
import contextvars
4039
import logging
4140
import warnings
4241
from collections.abc import AsyncIterator, Awaitable, Callable
@@ -74,8 +73,6 @@ async def main():
7473

7574
LifespanResultT = TypeVar("LifespanResultT", default=Any)
7675

77-
request_ctx: contextvars.ContextVar[ServerRequestContext[Any]] = contextvars.ContextVar("request_ctx")
78-
7976

8077
class NotificationOptions:
8178
def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False):
@@ -474,11 +471,7 @@ async def _handle_request(
474471
close_sse_stream=close_sse_stream_cb,
475472
close_standalone_sse_stream=close_standalone_sse_stream_cb,
476473
)
477-
token = request_ctx.set(ctx)
478-
try:
479-
response = await handler(ctx, req.params)
480-
finally:
481-
request_ctx.reset(token)
474+
response = await handler(ctx, req.params)
482475
except MCPError as err:
483476
response = err.error
484477
except anyio.get_cancelled_exc_class():

src/mcp/server/mcpserver/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from mcp.types import Icon
44

5-
from .server import Context, MCPServer
5+
from .context import Context
6+
from .server import MCPServer
67
from .utilities.types import Audio, Image
78

89
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from typing import TYPE_CHECKING, Any, Generic, Literal
5+
6+
from pydantic import AnyUrl, BaseModel
7+
8+
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
9+
from mcp.server.elicitation import (
10+
ElicitationResult,
11+
ElicitSchemaModelT,
12+
UrlElicitationResult,
13+
elicit_url,
14+
elicit_with_validation,
15+
)
16+
from mcp.server.lowlevel.helper_types import ReadResourceContents
17+
18+
if TYPE_CHECKING:
19+
from mcp.server.mcpserver.server import MCPServer
20+
21+
22+
class Context(BaseModel, Generic[LifespanContextT, RequestT]):
23+
"""Context object providing access to MCP capabilities.
24+
25+
This provides a cleaner interface to MCP's RequestContext functionality.
26+
It gets injected into tool and resource functions that request it via type hints.
27+
28+
To use context in a tool function, add a parameter with the Context type annotation:
29+
30+
```python
31+
@server.tool()
32+
async def my_tool(x: int, ctx: Context) -> str:
33+
# Log messages to the client
34+
await ctx.info(f"Processing {x}")
35+
await ctx.debug("Debug info")
36+
await ctx.warning("Warning message")
37+
await ctx.error("Error message")
38+
39+
# Report progress
40+
await ctx.report_progress(50, 100)
41+
42+
# Access resources
43+
data = await ctx.read_resource("resource://data")
44+
45+
# Get request info
46+
request_id = ctx.request_id
47+
client_id = ctx.client_id
48+
49+
return str(x)
50+
```
51+
52+
The context parameter name can be anything as long as it's annotated with Context.
53+
The context is optional - tools that don't need it can omit the parameter.
54+
"""
55+
56+
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
57+
_mcp_server: MCPServer | None
58+
59+
# TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely.
60+
def __init__(
61+
self,
62+
*,
63+
request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None,
64+
mcp_server: MCPServer | None = None,
65+
# TODO(Marcelo): We should drop this kwargs parameter.
66+
**kwargs: Any,
67+
):
68+
super().__init__(**kwargs)
69+
self._request_context = request_context
70+
self._mcp_server = mcp_server
71+
72+
@property
73+
def mcp_server(self) -> MCPServer:
74+
"""Access to the MCPServer instance."""
75+
if self._mcp_server is None: # pragma: no cover
76+
raise ValueError("Context is not available outside of a request")
77+
return self._mcp_server # pragma: no cover
78+
79+
@property
80+
def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
81+
"""Access to the underlying request context."""
82+
if self._request_context is None: # pragma: no cover
83+
raise ValueError("Context is not available outside of a request")
84+
return self._request_context
85+
86+
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
87+
"""Report progress for the current operation.
88+
89+
Args:
90+
progress: Current progress value (e.g., 24)
91+
total: Optional total value (e.g., 100)
92+
message: Optional message (e.g., "Starting render...")
93+
"""
94+
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None
95+
96+
if progress_token is None: # pragma: no cover
97+
return
98+
99+
await self.request_context.session.send_progress_notification(
100+
progress_token=progress_token,
101+
progress=progress,
102+
total=total,
103+
message=message,
104+
related_request_id=self.request_id,
105+
)
106+
107+
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
108+
"""Read a resource by URI.
109+
110+
Args:
111+
uri: Resource URI to read
112+
113+
Returns:
114+
The resource content as either text or bytes
115+
"""
116+
assert self._mcp_server is not None, "Context is not available outside of a request"
117+
return await self._mcp_server.read_resource(uri, self)
118+
119+
async def elicit(
120+
self,
121+
message: str,
122+
schema: type[ElicitSchemaModelT],
123+
) -> ElicitationResult[ElicitSchemaModelT]:
124+
"""Elicit information from the client/user.
125+
126+
This method can be used to interactively ask for additional information from the
127+
client within a tool's execution. The client might display the message to the
128+
user and collect a response according to the provided schema. If the client
129+
is an agent, it might decide how to handle the elicitation -- either by asking
130+
the user or automatically generating a response.
131+
132+
Args:
133+
message: Message to present to the user
134+
schema: A Pydantic model class defining the expected response structure.
135+
According to the specification, only primitive types are allowed.
136+
137+
Returns:
138+
An ElicitationResult containing the action taken and the data if accepted
139+
140+
Note:
141+
Check the result.action to determine if the user accepted, declined, or cancelled.
142+
The result.data will only be populated if action is "accept" and validation succeeded.
143+
"""
144+
145+
return await elicit_with_validation(
146+
session=self.request_context.session,
147+
message=message,
148+
schema=schema,
149+
related_request_id=self.request_id,
150+
)
151+
152+
async def elicit_url(
153+
self,
154+
message: str,
155+
url: str,
156+
elicitation_id: str,
157+
) -> UrlElicitationResult:
158+
"""Request URL mode elicitation from the client.
159+
160+
This directs the user to an external URL for out-of-band interactions
161+
that must not pass through the MCP client. Use this for:
162+
- Collecting sensitive credentials (API keys, passwords)
163+
- OAuth authorization flows with third-party services
164+
- Payment and subscription flows
165+
- Any interaction where data should not pass through the LLM context
166+
167+
The response indicates whether the user consented to navigate to the URL.
168+
The actual interaction happens out-of-band. When the elicitation completes,
169+
call `ctx.session.send_elicit_complete(elicitation_id)` to notify the client.
170+
171+
Args:
172+
message: Human-readable explanation of why the interaction is needed
173+
url: The URL the user should navigate to
174+
elicitation_id: Unique identifier for tracking this elicitation
175+
176+
Returns:
177+
UrlElicitationResult indicating accept, decline, or cancel
178+
"""
179+
return await elicit_url(
180+
session=self.request_context.session,
181+
message=message,
182+
url=url,
183+
elicitation_id=elicitation_id,
184+
related_request_id=self.request_id,
185+
)
186+
187+
async def log(
188+
self,
189+
level: Literal["debug", "info", "warning", "error"],
190+
message: str,
191+
*,
192+
logger_name: str | None = None,
193+
extra: dict[str, Any] | None = None,
194+
) -> None:
195+
"""Send a log message to the client.
196+
197+
Args:
198+
level: Log level (debug, info, warning, error)
199+
message: Log message
200+
logger_name: Optional logger name
201+
extra: Optional dictionary with additional structured data to include
202+
"""
203+
204+
if extra:
205+
log_data = {"message": message, **extra}
206+
else:
207+
log_data = message
208+
209+
await self.request_context.session.send_log_message(
210+
level=level,
211+
data=log_data,
212+
logger=logger_name,
213+
related_request_id=self.request_id,
214+
)
215+
216+
@property
217+
def client_id(self) -> str | None:
218+
"""Get the client ID if available."""
219+
return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover
220+
221+
@property
222+
def request_id(self) -> str:
223+
"""Get the unique ID for this request."""
224+
return str(self.request_context.request_id)
225+
226+
@property
227+
def session(self):
228+
"""Access to the underlying session for advanced usage."""
229+
return self.request_context.session
230+
231+
async def close_sse_stream(self) -> None:
232+
"""Close the SSE stream to trigger client reconnection.
233+
234+
This method closes the HTTP connection for the current request, triggering
235+
client reconnection. Events continue to be stored in the event store and will
236+
be replayed when the client reconnects with Last-Event-ID.
237+
238+
Use this to implement polling behavior during long-running operations -
239+
the client will reconnect after the retry interval specified in the priming event.
240+
241+
Note:
242+
This is a no-op if not using StreamableHTTP transport with event_store.
243+
The callback is only available when event_store is configured.
244+
"""
245+
if self._request_context and self._request_context.close_sse_stream: # pragma: no cover
246+
await self._request_context.close_sse_stream()
247+
248+
async def close_standalone_sse_stream(self) -> None:
249+
"""Close the standalone GET SSE stream to trigger client reconnection.
250+
251+
This method closes the HTTP connection for the standalone GET stream used
252+
for unsolicited server-to-client notifications. The client SHOULD reconnect
253+
with Last-Event-ID to resume receiving notifications.
254+
255+
Note:
256+
This is a no-op if not using StreamableHTTP transport with event_store.
257+
Currently, client reconnection for standalone GET streams is NOT
258+
implemented - this is a known gap.
259+
"""
260+
if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover
261+
await self._request_context.close_standalone_sse_stream()
262+
263+
# Convenience methods for common log levels
264+
async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
265+
"""Send a debug log message."""
266+
await self.log("debug", message, logger_name=logger_name, extra=extra)
267+
268+
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
269+
"""Send an info log message."""
270+
await self.log("info", message, logger_name=logger_name, extra=extra)
271+
272+
async def warning(
273+
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
274+
) -> None:
275+
"""Send a warning log message."""
276+
await self.log("warning", message, logger_name=logger_name, extra=extra)
277+
278+
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
279+
"""Send an error log message."""
280+
await self.log("error", message, logger_name=logger_name, extra=extra)

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
if TYPE_CHECKING:
1717
from mcp.server.context import LifespanContextT, RequestT
18-
from mcp.server.mcpserver.server import Context
18+
from mcp.server.mcpserver.context import Context
1919

2020

2121
class Message(BaseModel):
@@ -135,10 +135,14 @@ def from_function(
135135

136136
async def render(
137137
self,
138-
arguments: dict[str, Any] | None = None,
139-
context: Context[LifespanContextT, RequestT] | None = None,
138+
arguments: dict[str, Any] | None,
139+
context: Context[LifespanContextT, RequestT],
140140
) -> list[Message]:
141-
"""Render the prompt with arguments."""
141+
"""Render the prompt with arguments.
142+
143+
Raises:
144+
ValueError: If required arguments are missing, or if rendering fails.
145+
"""
142146
# Validate required arguments
143147
if self.arguments:
144148
required = {arg.name for arg in self.arguments if arg.required}

0 commit comments

Comments
 (0)