Skip to content

Commit 116fb4c

Browse files
committed
refactor: remove request_ctx ContextVar, thread Context explicitly
The request_ctx ContextVar in mcp.server.lowlevel.server was redundant with the ServerRequestContext already passed as the first argument to every _handle_* method. This removes the ContextVar entirely and threads Context explicitly. Changes: - MCPServer.get_context() removed — use ctx: Context parameter injection in tool/resource/prompt functions instead - MCPServer.call_tool/read_resource/get_prompt now accept an optional context: Context | None = None parameter; _handle_* methods construct the Context at the lowlevel boundary and pass it through - Context class moved from server.py to its own context.py module (still re-exported from mcp.server.mcpserver) - Tool.run/Prompt.render/ResourceTemplate.create_resource now raise a clear error if the registered function requires a Context but none was provided, instead of silently injecting None Github-Issue: #2112 Github-Issue: #1684
1 parent 62575ed commit 116fb4c

File tree

20 files changed

+441
-347
lines changed

20 files changed

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

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

Lines changed: 9 additions & 2 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):
@@ -138,7 +138,14 @@ async def render(
138138
arguments: dict[str, Any] | None = None,
139139
context: Context[LifespanContextT, RequestT] | None = None,
140140
) -> list[Message]:
141-
"""Render the prompt with arguments."""
141+
"""Render the prompt with arguments.
142+
143+
Raises:
144+
ValueError: If the prompt requires a Context but none was provided,
145+
if required arguments are missing, or if rendering fails.
146+
"""
147+
if self.context_kwarg is not None and context is None:
148+
raise ValueError(f"Prompt {self.name!r} requires a Context, but none was provided")
142149
# Validate required arguments
143150
if self.arguments:
144151
required = {arg.name for arg in self.arguments if arg.required}

0 commit comments

Comments
 (0)