Skip to content

Commit 4bfd423

Browse files
Merge pull request #2 from hackthemarket/copilot/pull-1406-into-fork
Add support for context-only resources (modelcontextprotocol#1406)
2 parents 35a9ccd + 5a28e3a commit 4bfd423

File tree

10 files changed

+150
-44
lines changed

10 files changed

+150
-44
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,
@@ -44,6 +44,6 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
4444
raise ValueError("Either name or uri must be provided")
4545

4646
@abc.abstractmethod
47-
async def read(self) -> str | bytes:
47+
async def read(self, context: Any | None = None) -> str | bytes:
4848
"""Read the resource content."""
4949
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 Annotations, 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

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

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

@@ -100,6 +109,7 @@ def from_function(
100109
mime_type=mime_type or "text/plain",
101110
fn=fn,
102111
icons=icons,
112+
context_kwarg=context_kwarg,
103113
annotations=annotations,
104114
)
105115

@@ -137,7 +147,7 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo
137147
mime_type = info.data.get("mime_type", "text/plain")
138148
return not mime_type.startswith("text/")
139149

140-
async def read(self) -> str | bytes:
150+
async def read(self, context: Any | None = None) -> str | bytes:
141151
"""Read the file content."""
142152
try:
143153
if self.is_binary:
@@ -153,7 +163,7 @@ class HttpResource(Resource):
153163
url: str = Field(description="URL to fetch content from")
154164
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
155165

156-
async def read(self) -> str | bytes:
166+
async def read(self, context: Any | None = None) -> str | bytes:
157167
"""Read the HTTP content."""
158168
async with httpx.AsyncClient() as client:
159169
response = await client.get(self.url)
@@ -191,7 +201,7 @@ def list_files(self) -> list[Path]:
191201
except Exception as e:
192202
raise ValueError(f"Error listing directory {self.path}: {e}")
193203

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

src/mcp/server/fastmcp/server.py

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

374374
try:
375-
content = await resource.read()
375+
content = await resource.read(context=context)
376376
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
377377
except Exception as e:
378378
logger.exception(f"Error reading resource {uri}")
@@ -571,24 +571,22 @@ async def get_weather(city: str) -> str:
571571
)
572572

573573
def decorator(fn: AnyFunction) -> AnyFunction:
574-
# Check if this should be a template
575574
sig = inspect.signature(fn)
576-
has_uri_params = "{" in uri and "}" in uri
577-
has_func_params = bool(sig.parameters)
575+
context_param = find_context_parameter(fn)
576+
577+
# Determine effective parameters, excluding context
578+
effective_func_params = {p for p in sig.parameters.keys() if p != context_param}
578579

579-
if has_uri_params or has_func_params:
580-
# Check for Context parameter to exclude from validation
581-
context_param = find_context_parameter(fn)
580+
has_uri_params = "{" in uri and "}" in uri
581+
has_effective_func_params = bool(effective_func_params)
582582

583-
# Validate that URI params match function params (excluding context)
583+
if has_uri_params or has_effective_func_params:
584+
# Register as template
584585
uri_params = set(re.findall(r"{(\w+)}", uri))
585-
# We need to remove the context_param from the resource function if
586-
# there is any.
587-
func_params = {p for p in sig.parameters.keys() if p != context_param}
588586

589-
if uri_params != func_params:
587+
if uri_params != effective_func_params:
590588
raise ValueError(
591-
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"
589+
f"Mismatch between URI parameters {uri_params} and function parameters {effective_func_params}"
592590
)
593591

594592
# Register as template

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
@@ -20,6 +20,7 @@ def dummy_func() -> str:
2020
uri=AnyUrl("http://example.com/data"),
2121
name="test",
2222
fn=dummy_func,
23+
context_kwarg=None,
2324
)
2425
assert str(resource.uri) == "http://example.com/data"
2526

@@ -29,6 +30,7 @@ def dummy_func() -> str:
2930
uri=AnyUrl("invalid"),
3031
name="test",
3132
fn=dummy_func,
33+
context_kwarg=None,
3234
)
3335

3436
# Missing host
@@ -37,6 +39,7 @@ def dummy_func() -> str:
3739
uri=AnyUrl("http://"),
3840
name="test",
3941
fn=dummy_func,
42+
context_kwarg=None,
4043
)
4144

4245
def test_resource_name_from_uri(self):
@@ -48,6 +51,7 @@ def dummy_func() -> str:
4851
resource = FunctionResource(
4952
uri=AnyUrl("resource://my-resource"),
5053
fn=dummy_func,
54+
context_kwarg=None,
5155
)
5256
assert resource.name == "resource://my-resource"
5357

@@ -61,13 +65,15 @@ def dummy_func() -> str:
6165
with pytest.raises(ValueError, match="Either name or uri must be provided"):
6266
FunctionResource(
6367
fn=dummy_func,
68+
context_kwarg=None,
6469
)
6570

6671
# Explicit name takes precedence over URI
6772
resource = FunctionResource(
6873
uri=AnyUrl("resource://uri-name"),
6974
name="explicit-name",
7075
fn=dummy_func,
76+
context_kwarg=None,
7177
)
7278
assert resource.name == "explicit-name"
7379

@@ -81,6 +87,7 @@ def dummy_func() -> str:
8187
resource = FunctionResource(
8288
uri=AnyUrl("resource://test"),
8389
fn=dummy_func,
90+
context_kwarg=None,
8491
)
8592
assert resource.mime_type == "text/plain"
8693

@@ -89,6 +96,7 @@ def dummy_func() -> str:
8996
uri=AnyUrl("resource://test"),
9097
fn=dummy_func,
9198
mime_type="application/json",
99+
context_kwarg=None,
92100
)
93101
assert resource.mime_type == "application/json"
94102

0 commit comments

Comments
 (0)