Skip to content

Commit dcad00e

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

File tree

7 files changed

+131
-45
lines changed

7 files changed

+131
-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: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
from collections.abc import Callable
66
from pathlib import Path
7-
from typing import Any
7+
from typing import Any, Optional
88

99
import anyio
1010
import anyio.to_thread
@@ -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: Optional[str] = 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/test_integration.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
sampling,
3333
structured_output,
3434
tool_progress,
35+
context_resource,
3536
)
3637
from mcp.client.session import ClientSession
3738
from mcp.client.sse import sse_client
@@ -124,6 +125,8 @@ def run_server_with_transport(module_name: str, port: int, transport: str) -> No
124125
mcp = fastmcp_quickstart.mcp
125126
elif module_name == "structured_output":
126127
mcp = structured_output.mcp
128+
elif module_name == "context_resource":
129+
mcp = context_resource.mcp
127130
else:
128131
raise ImportError(f"Unknown module: {module_name}")
129132

@@ -697,3 +700,42 @@ async def test_structured_output(server_transport: str, server_url: str) -> None
697700
assert "sunny" in result_text # condition
698701
assert "45" in result_text # humidity
699702
assert "5.2" in result_text # wind_speed
703+
704+
705+
@pytest.mark.anyio
706+
@pytest.mark.parametrize(
707+
"server_transport",
708+
[
709+
("context_resource", "sse"),
710+
("context_resource", "streamable-http"),
711+
],
712+
indirect=True,
713+
)
714+
async def test_context_only_resource(server_transport: str, server_url: str) -> None:
715+
"""Test that a resource with only a context argument is registered as a regular resource."""
716+
transport = server_transport
717+
client_cm = create_client_for_transport(transport, server_url)
718+
719+
async with client_cm as client_streams:
720+
read_stream, write_stream = unpack_streams(client_streams)
721+
async with ClientSession(read_stream, write_stream) as session:
722+
# Test initialization
723+
result = await session.initialize()
724+
assert isinstance(result, InitializeResult)
725+
assert result.serverInfo.name == "Context Resource Example"
726+
727+
# Check that it is not in templates
728+
templates = await session.list_resource_templates()
729+
assert len(templates.resourceTemplates) == 0
730+
731+
# Check that it is in resources
732+
resources = await session.list_resources()
733+
assert len(resources.resources) == 1
734+
resource = resources.resources[0]
735+
assert resource.uri == AnyUrl("resource://only_context")
736+
737+
# Check that we can read it
738+
read_result = await session.read_resource(AnyUrl("resource://only_context"))
739+
assert len(read_result.contents) == 1
740+
assert isinstance(read_result.contents[0], TextResourceContents)
741+
assert read_result.contents[0].text == "Resource with only context injected"

tests/server/fastmcp/test_server.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,33 @@ def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str:
10461046
assert isinstance(content, TextResourceContents)
10471047
assert "Resource 123 with context" in content.text
10481048

1049+
@pytest.mark.anyio
1050+
async def test_resource_only_context(self):
1051+
"""Test that resources without template args can receive context."""
1052+
mcp = FastMCP()
1053+
1054+
@mcp.resource("resource://only_context", name="resource_with_context_no_args")
1055+
def resource_only_context(ctx: Context[ServerSession, None]) -> str:
1056+
"""Resource that only receives context."""
1057+
assert ctx is not None
1058+
return "Resource with only context injected"
1059+
1060+
# Test via client
1061+
async with client_session(mcp._mcp_server) as client:
1062+
# Verify resource is registered via client
1063+
resources = await client.list_resources()
1064+
assert len(resources.resources) == 1
1065+
resource = resources.resources[0]
1066+
assert resource.uri == AnyUrl("resource://only_context")
1067+
assert resource.name == "resource_with_context_no_args"
1068+
1069+
# Test reading the resource
1070+
result = await client.read_resource(AnyUrl("resource://only_context"))
1071+
assert len(result.contents) == 1
1072+
content = result.contents[0]
1073+
assert isinstance(content, TextResourceContents)
1074+
assert content.text == "Resource with only context injected"
1075+
10491076
@pytest.mark.anyio
10501077
async def test_prompt_with_context(self):
10511078
"""Test that prompts can receive context parameter."""

0 commit comments

Comments
 (0)