Skip to content

Commit c277d9c

Browse files
committed
fix: Fix logic that determines standard resource vs. resource template to account for context param
1 parent b19fa6f commit c277d9c

File tree

9 files changed

+270
-57
lines changed

9 files changed

+270
-57
lines changed

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

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

3+
from __future__ import annotations
4+
35
import abc
4-
from typing import Annotated
6+
from typing import TYPE_CHECKING, Annotated
57

68
from pydantic import (
79
AnyUrl,
@@ -15,6 +17,11 @@
1517

1618
from mcp.types import Annotations, Icon
1719

20+
if TYPE_CHECKING:
21+
from mcp.server.fastmcp.server import Context
22+
from mcp.server.session import ServerSessionT
23+
from mcp.shared.context import LifespanContextT, RequestT
24+
1825

1926
class Resource(BaseModel, abc.ABC):
2027
"""Base class for all resources."""
@@ -44,6 +51,9 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
4451
raise ValueError("Either name or uri must be provided")
4552

4653
@abc.abstractmethod
47-
async def read(self) -> str | bytes:
54+
async def read(
55+
self,
56+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
57+
) -> str | bytes:
4858
"""Read the resource content."""
4959
pass # pragma: no cover

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22

33
from __future__ import annotations
44

5-
import inspect
65
import re
76
from collections.abc import Callable
87
from typing import TYPE_CHECKING, Any
98

10-
from pydantic import BaseModel, Field, validate_call
9+
from pydantic import BaseModel, Field
1110

1211
from mcp.server.fastmcp.resources.types import FunctionResource, Resource
13-
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context
14-
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
12+
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
13+
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata, is_async_callable
1514
from mcp.types import Annotations, Icon
1615

1716
if TYPE_CHECKING:
@@ -33,6 +32,10 @@ class ResourceTemplate(BaseModel):
3332
fn: Callable[..., Any] = Field(exclude=True)
3433
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
3534
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
35+
fn_metadata: FuncMetadata = Field(
36+
description="Metadata about the function including a pydantic model for arguments"
37+
)
38+
is_async: bool = Field(description="Whether the function is async")
3639

3740
@classmethod
3841
def from_function(
@@ -56,16 +59,15 @@ def from_function(
5659
if context_kwarg is None: # pragma: no branch
5760
context_kwarg = find_context_parameter(fn)
5861

62+
is_async = is_async_callable(fn)
63+
5964
# Get schema from func_metadata, excluding context parameter
6065
func_arg_metadata = func_metadata(
6166
fn,
6267
skip_names=[context_kwarg] if context_kwarg is not None else [],
6368
)
6469
parameters = func_arg_metadata.arg_model.model_json_schema()
6570

66-
# ensure the arguments are properly cast
67-
fn = validate_call(fn)
68-
6971
return cls(
7072
uri_template=uri_template,
7173
name=func_name,
@@ -77,6 +79,8 @@ def from_function(
7779
fn=fn,
7880
parameters=parameters,
7981
context_kwarg=context_kwarg,
82+
fn_metadata=func_arg_metadata,
83+
is_async=is_async,
8084
)
8185

8286
def matches(self, uri: str) -> dict[str, Any] | None:
@@ -96,13 +100,12 @@ async def create_resource(
96100
) -> Resource:
97101
"""Create a resource from the template with the given parameters."""
98102
try:
99-
# Add context to params if needed
100-
params = inject_context(self.fn, params, context, self.context_kwarg)
101-
102-
# Call function and check if result is a coroutine
103-
result = self.fn(**params)
104-
if inspect.iscoroutine(result):
105-
result = await result
103+
result = await self.fn_metadata.call_fn_with_arg_validation(
104+
self.fn,
105+
self.is_async,
106+
params,
107+
{self.context_kwarg: context} if self.context_kwarg is not None else None,
108+
)
106109

107110
return FunctionResource(
108111
uri=uri, # type: ignore

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

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,39 @@
11
"""Concrete resource implementations."""
22

3+
from __future__ import annotations
4+
35
import inspect
46
import json
57
from collections.abc import Callable
68
from pathlib import Path
7-
from typing import Any
9+
from typing import TYPE_CHECKING, Any
810

911
import anyio
1012
import anyio.to_thread
1113
import httpx
1214
import pydantic
1315
import pydantic_core
14-
from pydantic import AnyUrl, Field, ValidationInfo, validate_call
16+
from pydantic import AnyUrl, Field, ValidationInfo
1517

1618
from mcp.server.fastmcp.resources.base import Resource
19+
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
1720
from mcp.types import Annotations, Icon
1821

22+
if TYPE_CHECKING:
23+
from mcp.server.fastmcp.server import Context
24+
from mcp.server.session import ServerSessionT
25+
from mcp.shared.context import LifespanContextT, RequestT
26+
1927

2028
class TextResource(Resource):
2129
"""A resource that reads from a string."""
2230

2331
text: str = Field(description="Text content of the resource")
2432

25-
async def read(self) -> str:
33+
async def read(
34+
self,
35+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
36+
) -> str:
2637
"""Read the text content."""
2738
return self.text # pragma: no cover
2839

@@ -32,7 +43,10 @@ class BinaryResource(Resource):
3243

3344
data: bytes = Field(description="Binary content of the resource")
3445

35-
async def read(self) -> bytes:
46+
async def read(
47+
self,
48+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
49+
) -> bytes:
3650
"""Read the binary content."""
3751
return self.data # pragma: no cover
3852

@@ -50,13 +64,22 @@ class FunctionResource(Resource):
5064
- other types will be converted to JSON
5165
"""
5266

53-
fn: Callable[[], Any] = Field(exclude=True)
67+
fn: Callable[..., Any] = Field(exclude=True)
68+
context_kwarg: str | None = Field(default=None, description="Name of the kwarg that should receive context")
5469

55-
async def read(self) -> str | bytes:
70+
async def read(
71+
self,
72+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
73+
) -> str | bytes:
5674
"""Read the resource by calling the wrapped function."""
5775
try:
58-
# Call the function first to see if it returns a coroutine
59-
result = self.fn()
76+
# Inject context if needed
77+
kwargs: dict[str, Any] = {}
78+
if self.context_kwarg is not None and context is not None:
79+
kwargs[self.context_kwarg] = context
80+
81+
# Call the function
82+
result = self.fn(**kwargs)
6083
# If it's a coroutine, await it
6184
if inspect.iscoroutine(result):
6285
result = await result
@@ -83,14 +106,14 @@ def from_function(
83106
mime_type: str | None = None,
84107
icons: list[Icon] | None = None,
85108
annotations: Annotations | None = None,
86-
) -> "FunctionResource":
109+
) -> FunctionResource:
87110
"""Create a FunctionResource from a function."""
88111
func_name = name or fn.__name__
89112
if func_name == "<lambda>": # pragma: no cover
90113
raise ValueError("You must provide a name for lambda functions")
91114

92-
# ensure the arguments are properly cast
93-
fn = validate_call(fn)
115+
# Find context parameter if it exists
116+
context_kwarg = find_context_parameter(fn)
94117

95118
return cls(
96119
uri=AnyUrl(uri),
@@ -99,6 +122,7 @@ def from_function(
99122
description=description or fn.__doc__ or "",
100123
mime_type=mime_type or "text/plain",
101124
fn=fn,
125+
context_kwarg=context_kwarg,
102126
icons=icons,
103127
annotations=annotations,
104128
)
@@ -137,7 +161,10 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo
137161
mime_type = info.data.get("mime_type", "text/plain")
138162
return not mime_type.startswith("text/")
139163

140-
async def read(self) -> str | bytes:
164+
async def read(
165+
self,
166+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
167+
) -> str | bytes:
141168
"""Read the file content."""
142169
try:
143170
if self.is_binary:
@@ -153,7 +180,10 @@ class HttpResource(Resource):
153180
url: str = Field(description="URL to fetch content from")
154181
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
155182

156-
async def read(self) -> str | bytes:
183+
async def read(
184+
self,
185+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
186+
) -> str | bytes:
157187
"""Read the HTTP content."""
158188
async with httpx.AsyncClient() as client: # pragma: no cover
159189
response = await client.get(self.url)
@@ -191,7 +221,10 @@ def list_files(self) -> list[Path]: # pragma: no cover
191221
except Exception as e:
192222
raise ValueError(f"Error listing directory {self.path}: {e}")
193223

194-
async def read(self) -> str: # Always returns JSON string # pragma: no cover
224+
async def read(
225+
self,
226+
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
227+
) -> str: # Always returns JSON string # pragma: no cover
195228
"""Read the directory listing."""
196229
try:
197230
files = await anyio.to_thread.run_sync(self.list_files)

src/mcp/server/fastmcp/server.py

Lines changed: 10 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: # pragma: no cover
378378
logger.exception(f"Error reading resource {uri}")
@@ -571,21 +571,18 @@ async def get_weather(city: str) -> str:
571571
)
572572

573573
def decorator(fn: AnyFunction) -> AnyFunction:
574-
# Check if this should be a template
574+
# Extract signature and parameters
575575
sig = inspect.signature(fn)
576-
has_uri_params = "{" in uri and "}" in uri
577-
has_func_params = bool(sig.parameters)
576+
uri_params = set(re.findall(r"{(\w+)}", uri))
577+
context_param = find_context_parameter(fn)
578+
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)
582-
583-
# Validate that URI params match function params (excluding context)
584-
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}
580+
# Determine if this should be a template
581+
has_uri_params = len(uri_params) != 0
582+
has_func_params = len(func_params) != 0
588583

584+
if has_uri_params or has_func_params:
585+
# Validate that URI params match function params
589586
if uri_params != func_params:
590587
raise ValueError(
591588
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"

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

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations as _annotations
22

3-
import functools
4-
import inspect
53
from collections.abc import Callable
64
from functools import cached_property
75
from typing import TYPE_CHECKING, Any
@@ -10,7 +8,7 @@
108

119
from mcp.server.fastmcp.exceptions import ToolError
1210
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
13-
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
11+
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata, is_async_callable
1412
from mcp.types import Icon, ToolAnnotations
1513

1614
if TYPE_CHECKING:
@@ -60,7 +58,7 @@ def from_function(
6058
raise ValueError("You must provide a name for lambda functions")
6159

6260
func_doc = description or fn.__doc__ or ""
63-
is_async = _is_async_callable(fn)
61+
is_async = is_async_callable(fn)
6462

6563
if context_kwarg is None: # pragma: no branch
6664
context_kwarg = find_context_parameter(fn)
@@ -107,12 +105,3 @@ async def run(
107105
return result
108106
except Exception as e:
109107
raise ToolError(f"Error executing tool {self.name}: {e}") from e
110-
111-
112-
def _is_async_callable(obj: Any) -> bool:
113-
while isinstance(obj, functools.partial): # pragma: no cover
114-
obj = obj.func
115-
116-
return inspect.iscoroutinefunction(obj) or (
117-
callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None))
118-
)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import inspect
23
import json
34
from collections.abc import Awaitable, Callable, Sequence
@@ -531,3 +532,13 @@ def _convert_to_content(
531532
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
532533

533534
return [TextContent(type="text", text=result)]
535+
536+
537+
def is_async_callable(obj: Any) -> bool:
538+
"""Check if an object is an async callable."""
539+
while isinstance(obj, functools.partial): # pragma: no cover
540+
obj = obj.func
541+
542+
return inspect.iscoroutinefunction(obj) or (
543+
callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None))
544+
)

0 commit comments

Comments
 (0)