Skip to content

Commit 823a081

Browse files
committed
Fix typed Context in resources and prompts
1 parent a4f4ccd commit 823a081

8 files changed

Lines changed: 106 additions & 37 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 the same typed `ctx` parameter. 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 in tools, resources, and prompts.
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1712,7 +1712,7 @@ async def my_tool(ctx: Context) -> str: ...
17121712
async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
17131713
```
17141714

1715-
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.
1715+
The parametrized `Context[MyLifespanState]` annotation works on `@mcp.tool()`, `@mcp.prompt()`, and templated `@mcp.resource("scheme://{param}")` handlers. The SDK validates client-supplied arguments separately and passes the server-created context directly, preserving its request state and the static type of `ctx.request_context.lifespan_context`.
17161716

17171717
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
17181718

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
import anyio.to_thread
1010
import pydantic_core
1111
from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent
12-
from pydantic import BaseModel, Field, TypeAdapter, validate_call
12+
from pydantic import BaseModel, Field, TypeAdapter
1313

14-
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
14+
from mcp.server.mcpserver.utilities.context_injection import (
15+
find_context_parameter,
16+
inject_context,
17+
validate_call_with_injected_context,
18+
)
1519
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1620
from mcp.shared._callable_inspection import is_async_callable
1721
from mcp.shared.exceptions import MCPError
@@ -126,7 +130,7 @@ def from_function(
126130
)
127131

128132
# ensure the arguments are properly cast
129-
fn = validate_call(fn)
133+
fn = validate_call_with_injected_context(fn, context_kwarg)
130134

131135
return cls(
132136
name=func_name,

‎src/mcp/server/mcpserver/resources/templates.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99

1010
import anyio.to_thread
1111
from mcp_types import Annotations, Icon, InputRequiredResult
12-
from pydantic import BaseModel, Field, validate_call
12+
from pydantic import BaseModel, Field
1313

1414
from mcp.server.mcpserver.exceptions import ResourceError
1515
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
16-
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
16+
from mcp.server.mcpserver.utilities.context_injection import (
17+
find_context_parameter,
18+
inject_context,
19+
validate_call_with_injected_context,
20+
)
1721
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1822
from mcp.server.mcpserver.utilities.logging import get_logger
1923
from mcp.shared._callable_inspection import is_async_callable
@@ -160,7 +164,7 @@ def from_function(
160164
parameters = func_arg_metadata.arg_model.model_json_schema()
161165

162166
# ensure the arguments are properly cast
163-
fn = validate_call(fn)
167+
fn = validate_call_with_injected_context(fn, context_kwarg)
164168

165169
return cls(
166170
uri_template=uri_template,

‎src/mcp/server/mcpserver/utilities/context_injection.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
from __future__ import annotations
44

5+
import functools
56
import inspect
67
import typing
78
from collections.abc import Callable
89
from typing import Any
910

11+
from pydantic import SkipValidation, validate_call
12+
1013
from mcp.server.mcpserver.context import Context
14+
from mcp.shared._callable_inspection import is_async_callable
1115

1216

1317
def find_context_parameter(fn: Callable[..., Any]) -> str | None:
@@ -66,3 +70,59 @@ def inject_context(
6670
if context_kwarg is not None and context is not None:
6771
return {**kwargs, context_kwarg: context}
6872
return kwargs
73+
74+
75+
def validate_call_with_injected_context(
76+
fn: Callable[..., Any],
77+
context_kwarg: str | None,
78+
) -> Callable[..., Any]:
79+
"""Validate a handler call without re-validating its injected context."""
80+
if context_kwarg is None:
81+
return validate_call(fn)
82+
83+
signature = inspect.signature(fn, eval_str=True)
84+
context_parameter = signature.parameters.get(context_kwarg)
85+
if context_parameter is None:
86+
return validate_call(fn)
87+
88+
context_annotation = context_parameter.annotation
89+
if context_annotation is inspect.Parameter.empty:
90+
context_annotation = Any
91+
skipped_context_annotation = SkipValidation[context_annotation]
92+
validation_signature = signature.replace(
93+
parameters=[
94+
parameter.replace(annotation=skipped_context_annotation) if parameter.name == context_kwarg else parameter
95+
for parameter in signature.parameters.values()
96+
]
97+
)
98+
validation_annotations = {
99+
parameter.name: parameter.annotation
100+
for parameter in validation_signature.parameters.values()
101+
if parameter.annotation is not inspect.Parameter.empty
102+
}
103+
if validation_signature.return_annotation is not inspect.Signature.empty:
104+
validation_annotations["return"] = validation_signature.return_annotation
105+
106+
if is_async_callable(fn):
107+
108+
@functools.wraps(fn)
109+
async def async_forwarding(*args: Any, **kwargs: Any) -> Any:
110+
return await fn(*args, **kwargs)
111+
112+
forwarding: Callable[..., Any] = async_forwarding
113+
else:
114+
115+
@functools.wraps(fn)
116+
def sync_forwarding(*args: Any, **kwargs: Any) -> Any:
117+
return fn(*args, **kwargs)
118+
119+
forwarding = sync_forwarding
120+
121+
# Pydantic builds its validator from these temporary annotations. Restore the
122+
# handler's public signature after validation is compiled.
123+
setattr(forwarding, "__signature__", validation_signature)
124+
forwarding.__annotations__ = validation_annotations
125+
validated = validate_call(forwarding)
126+
setattr(validated, "__signature__", signature)
127+
validated.__annotations__ = fn.__annotations__
128+
return validated

‎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_resources_and_prompts() -> None:
78+
"""`Context[AppContext]` preserves typed lifespan state in resources and prompts."""
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/resources/test_resource_template.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,21 @@ def my_func(key: str, value: int) -> dict[str, Any]:
221221
data = json.loads(content)
222222
assert data == {"key": "foo", "value": 123}
223223

224+
@pytest.mark.anyio
225+
async def test_create_resource_preserves_context_with_variadic_kwargs(self):
226+
"""Injected context bypasses validation without changing **kwargs handling."""
227+
context = Context()
228+
229+
def my_func(ctx: Context[None], **kwargs: str) -> str:
230+
assert ctx is context
231+
return kwargs["key"]
232+
233+
template = ResourceTemplate.from_function(fn=my_func, uri_template="test://{key}")
234+
resource = await template.create_resource("test://value", {"key": "value"}, context)
235+
236+
assert isinstance(resource, FunctionResource)
237+
assert await resource.read() == "value"
238+
224239
@pytest.mark.anyio
225240
async def test_template_error(self):
226241
"""Test error handling in template resource creation."""

‎tests/server/mcpserver/test_server.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,9 +1197,9 @@ async def test_resource_with_context(self):
11971197
mcp = MCPServer()
11981198

11991199
@mcp.resource("resource://context/{name}")
1200-
def resource_with_context(name: str, ctx: Context) -> str:
1200+
async def resource_with_context(name: str, ctx: Context[None]) -> str:
12011201
"""Resource that receives context."""
1202-
assert ctx is not None
1202+
assert ctx.request_context is not None
12031203
return f"Resource {name} - context injected"
12041204

12051205
# Verify template has context_kwarg set
@@ -1280,9 +1280,9 @@ async def test_prompt_with_context(self):
12801280
mcp = MCPServer()
12811281

12821282
@mcp.prompt("prompt_with_ctx")
1283-
def prompt_with_context(text: str, ctx: Context) -> str:
1283+
def prompt_with_context(text: str, ctx: Context[None]) -> str:
12841284
"""Prompt that expects context."""
1285-
assert ctx is not None
1285+
assert ctx.request_context is not None
12861286
return f"Prompt '{text}' - context injected"
12871287

12881288
# Test via client

0 commit comments

Comments
 (0)