Skip to content

Commit cbcdec5

Browse files
committed
Add support for context-only resources (#1405)
1 parent 71889d7 commit cbcdec5

File tree

10 files changed

+150
-45
lines changed

10 files changed

+150
-45
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from mcp.server.fastmcp import Context, FastMCP
2+
from mcp.server.session import ServerSession
3+
4+
mcp = FastMCP(name="Context Resource Example")
5+
6+
7+
@mcp.resource("resource://only_context")
8+
def resource_only_context(ctx: Context[ServerSession, None]) -> str:
9+
"""Resource that only receives context."""
10+
assert ctx is not None
11+
return "Resource with only context injected"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Base classes and interfaces for FastMCP resources."""
22

33
import abc
4-
from typing import Annotated
4+
from typing import Annotated, Any
55

66
from pydantic import (
77
AnyUrl,
@@ -43,6 +43,6 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
4343
raise ValueError("Either name or uri must be provided")
4444

4545
@abc.abstractmethod
46-
async def read(self) -> str | bytes:
46+
async def read(self, context: Any | None = None) -> str | bytes:
4747
"""Read the resource content."""
4848
pass

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

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pydantic import AnyUrl, Field, ValidationInfo, validate_call
1515

1616
from mcp.server.fastmcp.resources.base import Resource
17+
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
1718
from mcp.types import Icon
1819

1920

@@ -22,7 +23,7 @@ class TextResource(Resource):
2223

2324
text: str = Field(description="Text content of the resource")
2425

25-
async def read(self) -> str:
26+
async def read(self, context: Any | None = None) -> str:
2627
"""Read the text content."""
2728
return self.text
2829

@@ -32,7 +33,7 @@ class BinaryResource(Resource):
3233

3334
data: bytes = Field(description="Binary content of the resource")
3435

35-
async def read(self) -> bytes:
36+
async def read(self, context: Any | None = None) -> bytes:
3637
"""Read the binary content."""
3738
return self.data
3839

@@ -51,24 +52,30 @@ class FunctionResource(Resource):
5152
"""
5253

5354
fn: Callable[[], Any] = Field(exclude=True)
55+
context_kwarg: str | None = Field(None, exclude=True)
56+
57+
async def read(self, context: Any | None = None) -> str | bytes:
58+
"""Read the resource content by calling the function."""
59+
args = {}
60+
if self.context_kwarg:
61+
args[self.context_kwarg] = context
5462

55-
async def read(self) -> str | bytes:
56-
"""Read the resource by calling the wrapped function."""
5763
try:
58-
# Call the function first to see if it returns a coroutine
59-
result = self.fn()
60-
# If it's a coroutine, await it
61-
if inspect.iscoroutine(result):
62-
result = await result
63-
64-
if isinstance(result, Resource):
65-
return await result.read()
66-
elif isinstance(result, bytes):
67-
return result
68-
elif isinstance(result, str):
69-
return result
64+
if inspect.iscoroutinefunction(self.fn):
65+
result = await self.fn(**args)
7066
else:
71-
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
67+
result = self.fn(**args)
68+
69+
if isinstance(result, str | bytes):
70+
return result
71+
if isinstance(result, pydantic.BaseModel):
72+
return result.model_dump_json(indent=2)
73+
74+
# For other types, convert to a JSON string
75+
try:
76+
return json.dumps(pydantic_core.to_jsonable_python(result))
77+
except pydantic_core.PydanticSerializationError:
78+
return json.dumps(str(result))
7279
except Exception as e:
7380
raise ValueError(f"Error reading resource {self.uri}: {e}")
7481

@@ -88,6 +95,8 @@ def from_function(
8895
if func_name == "<lambda>":
8996
raise ValueError("You must provide a name for lambda functions")
9097

98+
context_kwarg = find_context_parameter(fn)
99+
91100
# ensure the arguments are properly cast
92101
fn = validate_call(fn)
93102

@@ -99,6 +108,7 @@ def from_function(
99108
mime_type=mime_type or "text/plain",
100109
fn=fn,
101110
icons=icons,
111+
context_kwarg=context_kwarg,
102112
)
103113

104114

@@ -135,7 +145,7 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo
135145
mime_type = info.data.get("mime_type", "text/plain")
136146
return not mime_type.startswith("text/")
137147

138-
async def read(self) -> str | bytes:
148+
async def read(self, context: Any | None = None) -> str | bytes:
139149
"""Read the file content."""
140150
try:
141151
if self.is_binary:
@@ -151,7 +161,7 @@ class HttpResource(Resource):
151161
url: str = Field(description="URL to fetch content from")
152162
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
153163

154-
async def read(self) -> str | bytes:
164+
async def read(self, context: Any | None = None) -> str | bytes:
155165
"""Read the HTTP content."""
156166
async with httpx.AsyncClient() as client:
157167
response = await client.get(self.url)
@@ -189,7 +199,7 @@ def list_files(self) -> list[Path]:
189199
except Exception as e:
190200
raise ValueError(f"Error listing directory {self.path}: {e}")
191201

192-
async def read(self) -> str: # Always returns JSON string
202+
async def read(self, context: Any | None = None) -> str: # Always returns JSON string
193203
"""Read the directory listing."""
194204
try:
195205
files = await anyio.to_thread.run_sync(self.list_files)

src/mcp/server/fastmcp/server.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent
348348
raise ResourceError(f"Unknown resource: {uri}")
349349

350350
try:
351-
content = await resource.read()
351+
content = await resource.read(context=context)
352352
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
353353
except Exception as e:
354354
logger.exception(f"Error reading resource {uri}")
@@ -531,27 +531,24 @@ async def get_weather(city: str) -> str:
531531
)
532532

533533
def decorator(fn: AnyFunction) -> AnyFunction:
534-
# Check if this should be a template
535534
sig = inspect.signature(fn)
536-
has_uri_params = "{" in uri and "}" in uri
537-
has_func_params = bool(sig.parameters)
535+
context_param = find_context_parameter(fn)
536+
537+
# Determine effective parameters, excluding context
538+
effective_func_params = {p for p in sig.parameters.keys() if p != context_param}
538539

539-
if has_uri_params or has_func_params:
540-
# Check for Context parameter to exclude from validation
541-
context_param = find_context_parameter(fn)
540+
has_uri_params = "{" in uri and "}" in uri
541+
has_effective_func_params = bool(effective_func_params)
542542

543-
# Validate that URI params match function params (excluding context)
543+
if has_uri_params or has_effective_func_params:
544+
# Register as template
544545
uri_params = set(re.findall(r"{(\w+)}", uri))
545-
# We need to remove the context_param from the resource function if
546-
# there is any.
547-
func_params = {p for p in sig.parameters.keys() if p != context_param}
548546

549-
if uri_params != func_params:
547+
if uri_params != effective_func_params:
550548
raise ValueError(
551-
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"
549+
f"Mismatch between URI parameters {uri_params} and function parameters {effective_func_params}"
552550
)
553551

554-
# Register as template
555552
self._resource_manager.add_template(
556553
fn=fn,
557554
uri_template=uri,

src/mcp/server/fastmcp/utilities/context_injection.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,16 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None:
3131

3232
# Check each parameter's type hint
3333
for param_name, annotation in hints.items():
34-
# Handle direct Context type
34+
# Handle direct Context type and generic aliases of Context
35+
origin = typing.get_origin(annotation)
36+
37+
# Check if the annotation itself is Context or a subclass
3538
if inspect.isclass(annotation) and issubclass(annotation, Context):
3639
return param_name
3740

38-
# Handle generic types like Optional[Context]
39-
origin = typing.get_origin(annotation)
40-
if origin is not None:
41-
args = typing.get_args(annotation)
42-
for arg in args:
43-
if inspect.isclass(arg) and issubclass(arg, Context):
44-
return param_name
41+
# Check if it's a generic alias of Context (e.g., Context[...])
42+
if origin is not None and inspect.isclass(origin) and issubclass(origin, Context):
43+
return param_name
4544

4645
return None
4746

tests/server/fastmcp/resources/test_function_resources.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def my_func() -> str:
1818
name="test",
1919
description="test function",
2020
fn=my_func,
21+
context_kwarg=None,
2122
)
2223
assert str(resource.uri) == "fn://test"
2324
assert resource.name == "test"
@@ -36,6 +37,7 @@ def get_data() -> str:
3637
uri=AnyUrl("function://test"),
3738
name="test",
3839
fn=get_data,
40+
context_kwarg=None,
3941
)
4042
content = await resource.read()
4143
assert content == "Hello, world!"
@@ -52,6 +54,7 @@ def get_data() -> bytes:
5254
uri=AnyUrl("function://test"),
5355
name="test",
5456
fn=get_data,
57+
context_kwarg=None,
5558
)
5659
content = await resource.read()
5760
assert content == b"Hello, world!"
@@ -67,6 +70,7 @@ def get_data() -> dict[str, str]:
6770
uri=AnyUrl("function://test"),
6871
name="test",
6972
fn=get_data,
73+
context_kwarg=None,
7074
)
7175
content = await resource.read()
7276
assert isinstance(content, str)
@@ -83,6 +87,7 @@ def failing_func() -> str:
8387
uri=AnyUrl("function://test"),
8488
name="test",
8589
fn=failing_func,
90+
context_kwarg=None,
8691
)
8792
with pytest.raises(ValueError, match="Error reading resource function://test"):
8893
await resource.read()
@@ -98,6 +103,7 @@ class MyModel(BaseModel):
98103
uri=AnyUrl("function://test"),
99104
name="test",
100105
fn=lambda: MyModel(name="test"),
106+
context_kwarg=None,
101107
)
102108
content = await resource.read()
103109
assert content == '{\n "name": "test"\n}'
@@ -117,6 +123,7 @@ def get_data() -> CustomData:
117123
uri=AnyUrl("function://test"),
118124
name="test",
119125
fn=get_data,
126+
context_kwarg=None,
120127
)
121128
content = await resource.read()
122129
assert isinstance(content, str)
@@ -132,6 +139,7 @@ async def get_data() -> str:
132139
uri=AnyUrl("function://test"),
133140
name="test",
134141
fn=get_data,
142+
context_kwarg=None,
135143
)
136144
content = await resource.read()
137145
assert content == "Hello, world!"

tests/server/fastmcp/resources/test_resources.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def dummy_func() -> str:
1818
uri=AnyUrl("http://example.com/data"),
1919
name="test",
2020
fn=dummy_func,
21+
context_kwarg=None,
2122
)
2223
assert str(resource.uri) == "http://example.com/data"
2324

@@ -27,6 +28,7 @@ def dummy_func() -> str:
2728
uri=AnyUrl("invalid"),
2829
name="test",
2930
fn=dummy_func,
31+
context_kwarg=None,
3032
)
3133

3234
# Missing host
@@ -35,6 +37,7 @@ def dummy_func() -> str:
3537
uri=AnyUrl("http://"),
3638
name="test",
3739
fn=dummy_func,
40+
context_kwarg=None,
3841
)
3942

4043
def test_resource_name_from_uri(self):
@@ -46,6 +49,7 @@ def dummy_func() -> str:
4649
resource = FunctionResource(
4750
uri=AnyUrl("resource://my-resource"),
4851
fn=dummy_func,
52+
context_kwarg=None,
4953
)
5054
assert resource.name == "resource://my-resource"
5155

@@ -59,13 +63,15 @@ def dummy_func() -> str:
5963
with pytest.raises(ValueError, match="Either name or uri must be provided"):
6064
FunctionResource(
6165
fn=dummy_func,
66+
context_kwarg=None,
6267
)
6368

6469
# Explicit name takes precedence over URI
6570
resource = FunctionResource(
6671
uri=AnyUrl("resource://uri-name"),
6772
name="explicit-name",
6873
fn=dummy_func,
74+
context_kwarg=None,
6975
)
7076
assert resource.name == "explicit-name"
7177

@@ -79,6 +85,7 @@ def dummy_func() -> str:
7985
resource = FunctionResource(
8086
uri=AnyUrl("resource://test"),
8187
fn=dummy_func,
88+
context_kwarg=None,
8289
)
8390
assert resource.mime_type == "text/plain"
8491

@@ -87,6 +94,7 @@ def dummy_func() -> str:
8794
uri=AnyUrl("resource://test"),
8895
fn=dummy_func,
8996
mime_type="application/json",
97+
context_kwarg=None,
9098
)
9199
assert resource.mime_type == "application/json"
92100

0 commit comments

Comments
 (0)