Skip to content

Commit afa7edc

Browse files
authored
Keep the live Context when a prompt or template annotates Context[T] (#3624)
1 parent 889a863 commit afa7edc

5 files changed

Lines changed: 97 additions & 30 deletions

File tree

‎docs/handlers/lifespan.md‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve
4141

4242
`genre` is the only argument the model can pass. The lifespan is your server's business.
4343

44-
`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**.
44+
`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too. Everything `ctx` carries is in **[The Context](context.md)**.
4545

4646
### It really is typed
4747

@@ -51,19 +51,6 @@ That one type parameter is why `ctx.request_context.lifespan_context` **is** an
5151

5252
Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help.
5353

54-
!!! warning
55-
`Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or
56-
`@mcp.prompt()` function and every call to that handler fails. The client gets an error back,
57-
and the server log shows why:
58-
59-
```text
60-
Context is not available outside of a request
61-
```
62-
63-
In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is
64-
still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not
65-
the object.
66-
6754
!!! tip
6855
There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`,
6956
so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a
@@ -96,7 +83,7 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip
9683
* Code before the `yield` is startup. The `finally` after it is shutdown.
9784
* It runs once, around the whole life of the server, not per request.
9885
* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt.
99-
* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`.
86+
* `ctx: Context[AppContext]` makes that access fully typed.
10087
* No `lifespan=` means an empty `dict`, never `None`.
10188

10289
A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.md)**.

‎docs/migration.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1714,8 +1714,6 @@ async def my_tool(ctx: Context) -> str: ...
17141714
async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
17151715
```
17161716

1717-
The parametrized `Context[MyLifespanState]` annotation currently works only on `@mcp.tool()` handlers. On `@mcp.prompt()` and templated `@mcp.resource("scheme://{param}")` handlers, annotate the parameter as bare `Context` for now: these handlers are wrapped in `pydantic.validate_call`, which re-validates the injected `Context` into a fresh `Context[MyLifespanState]` detached from the request, so the first access to `ctx.request_id`, `ctx.session`, or `ctx.request_context` raises `ValueError: Context is not available outside of a request` (the client sees an internal server error, or `Error creating resource from template ...`). Bare `Context` still exposes `ctx.request_context.lifespan_context`; only its static type is lost.
1718-
17191717
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
17201718

17211719
`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers — `create_message`, `elicit` / `elicit_form` / `elicit_url`, `send_elicit_complete`, `list_roots`, `send_log_message`, `send_resource_updated`, `send_resource_list_changed` / `send_tool_list_changed` / `send_prompt_list_changed`, `send_ping`, `send_progress_notification`, and the new `report_progress` — plus `check_client_capability` and the read-only `client_params`, `client_capabilities`, `protocol_version`, and `can_send_request` properties. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.

‎src/mcp/server/mcpserver/context.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from typing import TYPE_CHECKING, Any, Generic, cast
55

66
from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel
7-
from pydantic import AnyUrl, BaseModel
8-
from typing_extensions import deprecated
7+
from pydantic import AnyUrl, BaseModel, ModelWrapValidatorHandler, model_validator
8+
from typing_extensions import Self, deprecated
99

1010
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
1111
from mcp.server.elicitation import (
@@ -84,6 +84,13 @@ def __init__(
8484
self._input_params = input_params
8585
self._subscriptions = subscriptions
8686

87+
@model_validator(mode="wrap")
88+
@classmethod
89+
def _keep_instance(cls, value: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
90+
"""Validate an existing `Context` to itself. `Context[T]` is a separate class at runtime, so
91+
pydantic would otherwise rebuild an instance of plain `Context` without its request state."""
92+
return cast(Self, value) if isinstance(value, Context) else handler(value)
93+
8794
@property
8895
def mcp_server(self) -> MCPServer:
8996
"""Access to the MCPServer instance."""

‎tests/docs_src/test_lifespan.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from mcp_types import TextContent, TextResourceContents
66

77
from docs_src.lifespan import tutorial001, tutorial002
8-
from mcp import Client, MCPError
8+
from mcp import Client
99
from mcp.server import MCPServer
1010
from mcp.server.mcpserver import Context
1111

@@ -74,8 +74,8 @@ def stock_report(ctx: Context) -> str:
7474
assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.")
7575

7676

77-
async def test_parameterized_context_is_tool_only(caplog: pytest.LogCaptureFixture) -> None:
78-
"""`Context[AppContext]` on a resource or prompt fails every call; the server logs the `ValueError`."""
77+
async def test_parameterized_context_reaches_the_lifespan_object_in_resources_and_prompts() -> None:
78+
"""`ctx: Context[AppContext]` gives a resource or prompt the same typed lifespan object a tool gets."""
7979
mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan)
8080

8181
@mcp.resource("books://{genre}/count")
@@ -89,14 +89,13 @@ def stock_report(ctx: Context[tutorial001.AppContext]) -> str:
8989
return f"Summarise a shelf of {ctx.request_context.lifespan_context.db.query()} books."
9090

9191
async with Client(mcp) as client:
92-
with pytest.raises(MCPError, match="Error creating resource from template"):
93-
await client.read_resource("books://poetry/count")
94-
assert "ValueError: Context is not available outside of a request" in caplog.text
95-
96-
caplog.clear()
97-
with pytest.raises(MCPError):
98-
await client.get_prompt("stock_report")
99-
assert "ValueError: Context is not available outside of a request" in caplog.text
92+
resource = await client.read_resource("books://poetry/count")
93+
assert resource.contents == [
94+
TextResourceContents(uri="books://poetry/count", mime_type="text/plain", text="3 books in 'poetry'.")
95+
]
96+
prompt = await client.get_prompt("stock_report")
97+
(message,) = prompt.messages
98+
assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.")
10099

101100

102101
async def test_default_lifespan_yields_an_empty_dict() -> None:

‎tests/server/mcpserver/test_server.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import base64
22
import logging
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
from dataclasses import dataclass
36
from pathlib import Path
47
from types import SimpleNamespace
58
from typing import Annotated, Any
@@ -1337,6 +1340,45 @@ def prompt_no_context(text: str) -> str:
13371340
assert content.text == "Prompt 'test' works"
13381341

13391342

1343+
async def test_parameterized_context_carries_the_request_into_templates_and_prompts():
1344+
"""`ctx: Context[AppState]` on a resource template or a sync or async prompt is the request's own
1345+
context, as it is on a tool: lifespan state and the negotiated protocol version are readable."""
1346+
1347+
@dataclass
1348+
class AppState:
1349+
greeting: str
1350+
1351+
@asynccontextmanager
1352+
async def lifespan(_: MCPServer[AppState]) -> AsyncIterator[AppState]:
1353+
yield AppState(greeting="Hello")
1354+
1355+
mcp = MCPServer(lifespan=lifespan)
1356+
1357+
@mcp.resource("greeting://{name}")
1358+
def greeting(name: str, ctx: Context[AppState]) -> str:
1359+
return f"{ctx.request_context.lifespan_context.greeting}, {name} ({ctx.protocol_version})"
1360+
1361+
@mcp.prompt()
1362+
def greet_sync(name: str, ctx: Context[AppState]) -> str:
1363+
return f"{ctx.request_context.lifespan_context.greeting}, {name} ({ctx.protocol_version})"
1364+
1365+
@mcp.prompt()
1366+
async def greet_async(name: str, ctx: Context[AppState]) -> str:
1367+
return f"{ctx.request_context.lifespan_context.greeting}, {name} ({ctx.protocol_version})"
1368+
1369+
async with Client(mcp, mode="2026-07-28") as client:
1370+
resource = await client.read_resource("greeting://Alice")
1371+
sync_prompt = await client.get_prompt("greet_sync", {"name": "Alice"})
1372+
async_prompt = await client.get_prompt("greet_async", {"name": "Alice"})
1373+
1374+
assert resource.contents == [
1375+
TextResourceContents(uri="greeting://Alice", mime_type="text/plain", text="Hello, Alice (2026-07-28)")
1376+
]
1377+
expected = [PromptMessage(role="user", content=TextContent(type="text", text="Hello, Alice (2026-07-28)"))]
1378+
assert sync_prompt.messages == expected
1379+
assert async_prompt.messages == expected
1380+
1381+
13401382
class TestServerPrompts:
13411383
"""Test prompt functionality in MCPServer server."""
13421384

@@ -2151,6 +2193,35 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
21512193
assert block.text == "Brief Alice (state=r1)"
21522194

21532195

2196+
async def test_prompt_with_parameterized_context_reads_input_responses_on_retry():
2197+
"""A prompt annotated `ctx: Context[T]` sees the retry's input_responses and request_state, so the
2198+
multi-round-trip flow completes instead of asking the same question again."""
2199+
mcp = MCPServer()
2200+
2201+
@mcp.prompt()
2202+
async def briefing(ctx: Context[dict[str, Any]]) -> list[UserMessage] | InputRequiredResult:
2203+
responses = ctx.input_responses
2204+
if responses and "who" in responses:
2205+
who = responses["who"]
2206+
assert isinstance(who, ElicitResult) and who.content is not None
2207+
return [UserMessage(content=f"Brief {who.content['name']} (state={ctx.request_state})")]
2208+
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="r1")
2209+
2210+
with anyio.fail_after(5):
2211+
async with Client(mcp, mode="2026-07-28") as client:
2212+
r1 = await client.session.get_prompt("briefing", allow_input_required=True)
2213+
assert isinstance(r1, InputRequiredResult)
2214+
2215+
r2 = await client.session.get_prompt(
2216+
"briefing",
2217+
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
2218+
request_state=r1.request_state,
2219+
allow_input_required=True,
2220+
)
2221+
assert isinstance(r2, GetPromptResult)
2222+
assert r2.messages == [PromptMessage(role="user", content=TextContent(type="text", text="Brief Alice (state=r1)"))]
2223+
2224+
21542225
async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error():
21552226
"""Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so
21562227
the runner rejects the frame with -32603 — the same posture the tools path has."""
@@ -3114,6 +3185,11 @@ def test_context_mcp_server_outside_request_raises() -> None:
31143185
_ = Context().mcp_server
31153186

31163187

3188+
def test_context_request_context_outside_request_raises() -> None:
3189+
with pytest.raises(ValueError, match="outside of a request"):
3190+
_ = Context().request_context
3191+
3192+
31173193
async def test_context_notify_outside_a_request_raises() -> None:
31183194
with pytest.raises(ValueError, match="outside of a request"):
31193195
await Context().notify_tools_changed()

0 commit comments

Comments
 (0)