Skip to content

Commit 38c059a

Browse files
committed
fix: Fix logic that determines standard resource vs. resource template to account for context param
1 parent 27279bc commit 38c059a

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__ importannotations
4+
35
importabc
4-
fromtypingimportAnnotated
6+
fromtypingimportTYPE_CHECKING, Annotated
57

68
frompydanticimport (
79
AnyUrl,
@@ -15,6 +17,11 @@
1517

1618
frommcp.typesimportAnnotations, Icon
1719

20+
ifTYPE_CHECKING:
21+
frommcp.server.fastmcp.serverimportContext
22+
frommcp.server.sessionimportServerSessionT
23+
frommcp.shared.contextimportLifespanContextT, RequestT
24+
1825

1926
classResource(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
raiseValueError("Either name or uri must be provided")
4552

4653
@abc.abstractmethod
47-
asyncdefread(self) ->str|bytes:
54+
asyncdefread(
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__ importannotations
44

5-
importinspect
65
importre
76
fromcollections.abcimportCallable
87
fromtypingimportTYPE_CHECKING, Any
98

10-
frompydanticimportBaseModel, Field, validate_call
9+
frompydanticimportBaseModel, Field
1110

1211
frommcp.server.fastmcp.resources.typesimportFunctionResource, Resource
13-
frommcp.server.fastmcp.utilities.context_injectionimportfind_context_parameter, inject_context
14-
frommcp.server.fastmcp.utilities.func_metadataimportfunc_metadata
12+
frommcp.server.fastmcp.utilities.context_injectionimportfind_context_parameter
13+
frommcp.server.fastmcp.utilities.func_metadataimportFuncMetadata, func_metadata, is_async_callable
1514
frommcp.typesimportAnnotations, Icon
1615

1716
ifTYPE_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
deffrom_function(
@@ -56,16 +59,15 @@ def from_function(
5659
ifcontext_kwargisNone: # 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] ifcontext_kwargisnotNoneelse [],
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
returncls(
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
defmatches(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-
ifinspect.iscoroutine(result):
105-
result=awaitresult
103+
result=awaitself.fn_metadata.call_fn_with_arg_validation(
104+
self.fn,
105+
self.is_async,
106+
params,
107+
{self.context_kwarg: context} ifself.context_kwargisnotNoneelseNone,
108+
)
106109

107110
returnFunctionResource(
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__ importannotations
4+
35
importinspect
46
importjson
57
fromcollections.abcimportCallable
68
frompathlibimportPath
7-
fromtypingimportAny
9+
fromtypingimportTYPE_CHECKING, Any
810

911
importanyio
1012
importanyio.to_thread
1113
importhttpx
1214
importpydantic
1315
importpydantic_core
14-
frompydanticimportAnyUrl, Field, ValidationInfo, validate_call
16+
frompydanticimportAnyUrl, Field, ValidationInfo
1517

1618
frommcp.server.fastmcp.resources.baseimportResource
19+
frommcp.server.fastmcp.utilities.context_injectionimportfind_context_parameter
1720
frommcp.typesimportAnnotations, Icon
1821

22+
ifTYPE_CHECKING:
23+
frommcp.server.fastmcp.serverimportContext
24+
frommcp.server.sessionimportServerSessionT
25+
frommcp.shared.contextimportLifespanContextT, RequestT
26+
1927

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

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

25-
asyncdefread(self) ->str:
33+
asyncdefread(
34+
self,
35+
context: Context[ServerSessionT, LifespanContextT, RequestT] |None=None,
36+
) ->str:
2637
"""Read the text content."""
2738
returnself.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-
asyncdefread(self) ->bytes:
46+
asyncdefread(
47+
self,
48+
context: Context[ServerSessionT, LifespanContextT, RequestT] |None=None,
49+
) ->bytes:
3650
"""Read the binary content."""
3751
returnself.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-
asyncdefread(self) ->str|bytes:
70+
asyncdefread(
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+
ifself.context_kwargisnotNoneandcontextisnotNone:
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
ifinspect.iscoroutine(result):
6285
result=awaitresult
@@ -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=nameorfn.__name__
89112
iffunc_name=="<lambda>": # pragma: no cover
90113
raiseValueError("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
returncls(
96119
uri=AnyUrl(uri),
@@ -99,6 +122,7 @@ def from_function(
99122
description=descriptionorfn.__doc__or"",
100123
mime_type=mime_typeor"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
returnnotmime_type.startswith("text/")
139163

140-
asyncdefread(self) ->str|bytes:
164+
asyncdefread(
165+
self,
166+
context: Context[ServerSessionT, LifespanContextT, RequestT] |None=None,
167+
) ->str|bytes:
141168
"""Read the file content."""
142169
try:
143170
ifself.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-
asyncdefread(self) ->str|bytes:
183+
asyncdefread(
184+
self,
185+
context: Context[ServerSessionT, LifespanContextT, RequestT] |None=None,
186+
) ->str|bytes:
157187
"""Read the HTTP content."""
158188
asyncwithhttpx.AsyncClient() asclient: # pragma: no cover
159189
response=awaitclient.get(self.url)
@@ -191,7 +221,10 @@ def list_files(self) -> list[Path]: # pragma: no cover
191221
exceptExceptionase:
192222
raiseValueError(f"Error listing directory {self.path}: {e}")
193223

194-
asyncdefread(self) ->str: # Always returns JSON string # pragma: no cover
224+
asyncdefread(
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=awaitanyio.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
raiseResourceError(f"Unknown resource: {uri}")
373373

374374
try:
375-
content=awaitresource.read()
375+
content=awaitresource.read(context=context)
376376
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
377377
exceptExceptionase: # 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
defdecorator(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="{"inuriand"}"inuri
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={pforpinsig.parameters.keys() ifp!=context_param}
578579

579-
ifhas_uri_paramsorhas_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={pforpinsig.parameters.keys() ifp!=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+
ifhas_uri_paramsorhas_func_params:
585+
# Validate that URI params match function params
589586
ifuri_params!=func_params:
590587
raiseValueError(
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__ importannotationsas_annotations
22

3-
importfunctools
4-
importinspect
53
fromcollections.abcimportCallable
64
fromfunctoolsimportcached_property
75
fromtypingimportTYPE_CHECKING, Any
@@ -10,7 +8,7 @@
108

119
frommcp.server.fastmcp.exceptionsimportToolError
1210
frommcp.server.fastmcp.utilities.context_injectionimportfind_context_parameter
13-
frommcp.server.fastmcp.utilities.func_metadataimportFuncMetadata, func_metadata
11+
frommcp.server.fastmcp.utilities.func_metadataimportFuncMetadata, func_metadata, is_async_callable
1412
frommcp.shared.tool_name_validationimportvalidate_and_warn_tool_name
1513
frommcp.typesimportIcon, ToolAnnotations
1614

@@ -63,7 +61,7 @@ def from_function(
6361
raiseValueError("You must provide a name for lambda functions")
6462

6563
func_doc=descriptionorfn.__doc__or""
66-
is_async=_is_async_callable(fn)
64+
is_async=is_async_callable(fn)
6765

6866
ifcontext_kwargisNone: # pragma: no branch
6967
context_kwarg=find_context_parameter(fn)
@@ -110,12 +108,3 @@ async def run(
110108
returnresult
111109
exceptExceptionase:
112110
raiseToolError(f"Error executing tool {self.name}: {e}") frome
113-
114-
115-
def_is_async_callable(obj: Any) ->bool:
116-
whileisinstance(obj, functools.partial): # pragma: no cover
117-
obj=obj.func
118-
119-
returninspect.iscoroutinefunction(obj) or (
120-
callable(obj) andinspect.iscoroutinefunction(getattr(obj, "__call__", None))
121-
)

‎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+
importfunctools
12
importinspect
23
importjson
34
fromcollections.abcimportAwaitable, 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+
defis_async_callable(obj: Any) ->bool:
538+
"""Check if an object is an async callable."""
539+
whileisinstance(obj, functools.partial): # pragma: no cover
540+
obj=obj.func
541+
542+
returninspect.iscoroutinefunction(obj) or (
543+
callable(obj) andinspect.iscoroutinefunction(getattr(obj, "__call__", None))
544+
)

0 commit comments

Comments
(0)