Skip to content

Commit f8c68af

Browse files
jonvetfubuloubu
authored andcommitted
fix: enables context injection into resources
1 parent c2ca8e0 commit f8c68af

File tree

6 files changed

+338
-10
lines changed

6 files changed

+338
-10
lines changed

src/mcp/server/fastmcp/resources/resource_manager.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Resource manager functionality."""
22

33
from collections.abc import Callable
4-
from typing import Any
4+
from typing import TYPE_CHECKING, Any
55

66
from pydantic import AnyUrl
77

88
from mcp.server.fastmcp.resources.base import Resource
99
from mcp.server.fastmcp.resources.templates import ResourceTemplate
1010
from mcp.server.fastmcp.utilities.logging import get_logger
1111

12+
if TYPE_CHECKING:
13+
from mcp.server.fastmcp.server import Context
14+
1215
logger = get_logger(__name__)
1316

1417

@@ -65,7 +68,9 @@ def add_template(
6568
self._templates[template.uri_template] = template
6669
return template
6770

68-
async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
71+
async def get_resource(
72+
self, uri: AnyUrl | str, context: "Context | None" = None
73+
) -> Resource | None:
6974
"""Get resource by URI, checking concrete resources first, then templates."""
7075
uri_str = str(uri)
7176
logger.debug("Getting resource", extra={"uri": uri_str})
@@ -78,7 +83,9 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
7883
for template in self._templates.values():
7984
if params := template.matches(uri_str):
8085
try:
81-
return await template.create_resource(uri_str, params)
86+
return await template.create_resource(
87+
uri_str, params, context=context
88+
)
8289
except Exception as e:
8390
raise ValueError(f"Error creating resource from template: {e}")
8491

src/mcp/server/fastmcp/resources/templates.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
import inspect
66
import re
77
from collections.abc import Callable
8-
from typing import Any
8+
from typing import TYPE_CHECKING, Any
99

10-
from pydantic import BaseModel, Field, TypeAdapter, validate_call
10+
from pydantic import BaseModel, Field, validate_call
1111

1212
from mcp.server.fastmcp.resources.types import FunctionResource, Resource
13+
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
14+
15+
if TYPE_CHECKING:
16+
from mcp.server.fastmcp.server import Context
1317

1418

1519
class ResourceTemplate(BaseModel):
@@ -27,6 +31,9 @@ class ResourceTemplate(BaseModel):
2731
parameters: dict[str, Any] = Field(
2832
description="JSON schema for function parameters"
2933
)
34+
context_kwarg: str | None = Field(
35+
None, description="Name of the kwarg that should receive context"
36+
)
3037

3138
@classmethod
3239
def from_function(
@@ -42,8 +49,24 @@ def from_function(
4249
if func_name == "<lambda>":
4350
raise ValueError("You must provide a name for lambda functions")
4451

45-
# Get schema from TypeAdapter - will fail if function isn't properly typed
46-
parameters = TypeAdapter(fn).json_schema()
52+
# Find context parameter if it exists
53+
context_kwarg = None
54+
sig = inspect.signature(fn)
55+
for param_name, param in sig.parameters.items():
56+
if (
57+
param.annotation.__name__ == "Context"
58+
if hasattr(param.annotation, "__name__")
59+
else False
60+
):
61+
context_kwarg = param_name
62+
break
63+
64+
# Get schema from func_metadata, excluding context parameter
65+
func_arg_metadata = func_metadata(
66+
fn,
67+
skip_names=[context_kwarg] if context_kwarg is not None else [],
68+
)
69+
parameters = func_arg_metadata.arg_model.model_json_schema()
4770

4871
# ensure the arguments are properly cast
4972
fn = validate_call(fn)
@@ -55,6 +78,7 @@ def from_function(
5578
mime_type=mime_type or "text/plain",
5679
fn=fn,
5780
parameters=parameters,
81+
context_kwarg=context_kwarg,
5882
)
5983

6084
def matches(self, uri: str) -> dict[str, Any] | None:
@@ -66,9 +90,15 @@ def matches(self, uri: str) -> dict[str, Any] | None:
6690
return match.groupdict()
6791
return None
6892

69-
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
93+
async def create_resource(
94+
self, uri: str, params: dict[str, Any], context: Context | None = None
95+
) -> Resource:
7096
"""Create a resource from the template with the given parameters."""
7197
try:
98+
# Add context to params if needed
99+
if self.context_kwarg is not None and context is not None:
100+
params = {**params, self.context_kwarg: context}
101+
72102
# Call function and check if result is a coroutine
73103
result = self.fn(**params)
74104
if inspect.iscoroutine(result):

src/mcp/server/fastmcp/server.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,8 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
230230
async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]:
231231
"""Read a resource by URI."""
232232

233-
resource = await self._resource_manager.get_resource(uri)
233+
context = self.get_context()
234+
resource = await self._resource_manager.get_resource(uri, context=context)
234235
if not resource:
235236
raise ResourceError(f"Unknown resource: {uri}")
236237

@@ -327,6 +328,10 @@ def resource(
327328
If the URI contains parameters (e.g. "resource://{param}") or the function
328329
has parameters, it will be registered as a template resource.
329330
331+
Resources can optionally request a Context object by adding a parameter with the
332+
Context type annotation. The context provides access to MCP capabilities like
333+
logging, progress reporting, and resource access.
334+
330335
Args:
331336
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
332337
name: Optional name for the resource
@@ -351,6 +356,12 @@ def get_weather(city: str) -> str:
351356
async def get_weather(city: str) -> str:
352357
data = await fetch_weather(city)
353358
return f"Weather for {city}: {data}"
359+
360+
@server.resource("resource://{city}/weather")
361+
async def get_weather(city: str, ctx: Context) -> str:
362+
await ctx.info(f"Getting weather for {city}")
363+
data = await fetch_weather(city)
364+
return f"Weather for {city}: {data}"
354365
"""
355366
# Check if user passed function directly instead of calling decorator
356367
if callable(uri):
@@ -367,7 +378,18 @@ def decorator(fn: AnyFunction) -> AnyFunction:
367378
if has_uri_params or has_func_params:
368379
# Validate that URI params match function params
369380
uri_params = set(re.findall(r"{(\w+)}", uri))
370-
func_params = set(inspect.signature(fn).parameters.keys())
381+
382+
# Get all function params except 'ctx' or any parameter of type Context
383+
sig = inspect.signature(fn)
384+
func_params = set()
385+
for param_name, param in sig.parameters.items():
386+
# Skip context parameters
387+
if param_name == "ctx" or (
388+
hasattr(param.annotation, "__name__")
389+
and param.annotation.__name__ == "Context"
390+
):
391+
continue
392+
func_params.add(param_name)
371393

372394
if uri_params != func_params:
373395
raise ValueError(

tests/server/fastmcp/resources/test_resource_manager.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,104 @@ def test_list_resources(self, temp_file: Path):
139139
resources = manager.list_resources()
140140
assert len(resources) == 2
141141
assert resources == [resource1, resource2]
142+
143+
@pytest.mark.anyio
144+
async def test_context_injection_in_template(self):
145+
"""Test that context is injected when getting a resource from a template."""
146+
from mcp.server.fastmcp import Context, FastMCP
147+
148+
manager = ResourceManager()
149+
150+
def resource_with_context(name: str, ctx: Context) -> str:
151+
assert isinstance(ctx, Context)
152+
return f"Hello, {name}!"
153+
154+
template = ResourceTemplate.from_function(
155+
fn=resource_with_context,
156+
uri_template="greet://{name}",
157+
name="greeter",
158+
)
159+
manager._templates[template.uri_template] = template
160+
161+
mcp = FastMCP()
162+
ctx = mcp.get_context()
163+
164+
resource = await manager.get_resource(AnyUrl("greet://world"), context=ctx)
165+
assert isinstance(resource, FunctionResource)
166+
content = await resource.read()
167+
assert content == "Hello, world!"
168+
169+
@pytest.mark.anyio
170+
async def test_context_injection_in_async_template(self):
171+
"""Test that context is properly injected in async template functions."""
172+
from mcp.server.fastmcp import Context, FastMCP
173+
174+
manager = ResourceManager()
175+
176+
async def async_resource(name: str, ctx: Context) -> str:
177+
assert isinstance(ctx, Context)
178+
return f"Async Hello, {name}!"
179+
180+
template = ResourceTemplate.from_function(
181+
fn=async_resource,
182+
uri_template="async-greet://{name}",
183+
name="async-greeter",
184+
)
185+
manager._templates[template.uri_template] = template
186+
187+
mcp = FastMCP()
188+
ctx = mcp.get_context()
189+
190+
resource = await manager.get_resource(
191+
AnyUrl("async-greet://world"), context=ctx
192+
)
193+
assert isinstance(resource, FunctionResource)
194+
content = await resource.read()
195+
assert content == "Async Hello, world!"
196+
197+
@pytest.mark.anyio
198+
async def test_optional_context_in_template(self):
199+
"""Test that context is optional when getting a resource from a template."""
200+
from mcp.server.fastmcp import Context
201+
202+
manager = ResourceManager()
203+
204+
def resource_with_optional_context(
205+
name: str, ctx: Context | None = None
206+
) -> str:
207+
return f"Hello, {name}!"
208+
209+
template = ResourceTemplate.from_function(
210+
fn=resource_with_optional_context,
211+
uri_template="greet://{name}",
212+
name="greeter",
213+
)
214+
manager._templates[template.uri_template] = template
215+
216+
resource = await manager.get_resource(AnyUrl("greet://world"))
217+
assert isinstance(resource, FunctionResource)
218+
content = await resource.read()
219+
assert content == "Hello, world!"
220+
221+
@pytest.mark.anyio
222+
async def test_context_error_handling_in_template(self):
223+
"""Test error handling when context injection fails in a template."""
224+
from mcp.server.fastmcp import Context, FastMCP
225+
226+
manager = ResourceManager()
227+
228+
def failing_resource(name: str, ctx: Context) -> str:
229+
raise ValueError("Test error")
230+
231+
template = ResourceTemplate.from_function(
232+
fn=failing_resource,
233+
uri_template="greet://{name}",
234+
name="greeter",
235+
)
236+
manager._templates[template.uri_template] = template
237+
238+
mcp = FastMCP()
239+
ctx = mcp.get_context()
240+
241+
with pytest.raises(ValueError, match="Error creating resource from template"):
242+
await manager.get_resource(AnyUrl("greet://world"), context=ctx)

0 commit comments

Comments
 (0)