fastmcp 2.1.2__py3-none-any.whl → 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,18 @@
1
1
  """Resource manager functionality."""
2
2
 
3
- import copy
4
3
  import inspect
5
- import re
6
4
  from collections.abc import Callable
7
5
  from typing import Any
8
6
 
9
7
  from pydantic import AnyUrl
10
8
 
11
- from fastmcp.exceptions import ResourceError
12
- from fastmcp.resources import FunctionResource, Resource
13
- from fastmcp.resources.template import ResourceTemplate
9
+ from fastmcp.exceptions import NotFoundError
10
+ from fastmcp.resources import FunctionResource
11
+ from fastmcp.resources.resource import Resource
12
+ from fastmcp.resources.template import (
13
+ ResourceTemplate,
14
+ match_uri_template,
15
+ )
14
16
  from fastmcp.settings import DuplicateBehavior
15
17
  from fastmcp.utilities.logging import get_logger
16
18
 
@@ -20,9 +22,20 @@ logger = get_logger(__name__)
20
22
  class ResourceManager:
21
23
  """Manages FastMCP resources."""
22
24
 
23
- def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
25
+ def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
24
26
  self._resources: dict[str, Resource] = {}
25
27
  self._templates: dict[str, ResourceTemplate] = {}
28
+
29
+ # Default to "warn" if None is provided
30
+ if duplicate_behavior is None:
31
+ duplicate_behavior = "warn"
32
+
33
+ if duplicate_behavior not in DuplicateBehavior.__args__:
34
+ raise ValueError(
35
+ f"Invalid duplicate_behavior: {duplicate_behavior}. "
36
+ f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
37
+ )
38
+
26
39
  self.duplicate_behavior = duplicate_behavior
27
40
 
28
41
  def add_resource_or_template_from_fn(
@@ -52,7 +65,7 @@ class ResourceManager:
52
65
  has_uri_params = "{" in uri and "}" in uri
53
66
  has_func_params = bool(inspect.signature(fn).parameters)
54
67
 
55
- if has_uri_params and has_func_params:
68
+ if has_uri_params or has_func_params:
56
69
  return self.add_template_from_fn(
57
70
  fn, uri, name, description, mime_type, tags
58
71
  )
@@ -99,32 +112,35 @@ class ResourceManager:
99
112
  )
100
113
  return self.add_resource(resource)
101
114
 
102
- def add_resource(self, resource: Resource) -> Resource:
115
+ def add_resource(self, resource: Resource, key: str | None = None) -> Resource:
103
116
  """Add a resource to the manager.
104
117
 
105
118
  Args:
106
119
  resource: A Resource instance to add
120
+ key: Optional URI to use as the storage key (if different from resource.uri)
107
121
  """
122
+ storage_key = key or str(resource.uri)
108
123
  logger.debug(
109
124
  "Adding resource",
110
125
  extra={
111
126
  "uri": resource.uri,
127
+ "storage_key": storage_key,
112
128
  "type": type(resource).__name__,
113
129
  "resource_name": resource.name,
114
130
  },
115
131
  )
116
- existing = self._resources.get(str(resource.uri))
132
+ existing = self._resources.get(storage_key)
117
133
  if existing:
118
- if self.duplicate_behavior == DuplicateBehavior.WARN:
119
- logger.warning(f"Resource already exists: {resource.uri}")
120
- self._resources[str(resource.uri)] = resource
121
- elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
122
- self._resources[str(resource.uri)] = resource
123
- elif self.duplicate_behavior == DuplicateBehavior.ERROR:
124
- raise ValueError(f"Resource already exists: {resource.uri}")
125
- elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
126
- pass
127
- self._resources[str(resource.uri)] = resource
134
+ if self.duplicate_behavior == "warn":
135
+ logger.warning(f"Resource already exists: {storage_key}")
136
+ self._resources[storage_key] = resource
137
+ elif self.duplicate_behavior == "replace":
138
+ self._resources[storage_key] = resource
139
+ elif self.duplicate_behavior == "error":
140
+ raise ValueError(f"Resource already exists: {storage_key}")
141
+ elif self.duplicate_behavior == "ignore":
142
+ return existing
143
+ self._resources[storage_key] = resource
128
144
  return resource
129
145
 
130
146
  def add_template_from_fn(
@@ -138,16 +154,6 @@ class ResourceManager:
138
154
  ) -> ResourceTemplate:
139
155
  """Create a template from a function."""
140
156
 
141
- # Validate that URI params match function params
142
- uri_params = set(re.findall(r"{(\w+)}", uri_template))
143
- func_params = set(inspect.signature(fn).parameters.keys())
144
-
145
- if uri_params != func_params:
146
- raise ValueError(
147
- f"Mismatch between URI parameters {uri_params} "
148
- f"and function parameters {func_params}"
149
- )
150
-
151
157
  template = ResourceTemplate.from_function(
152
158
  fn,
153
159
  uri_template=uri_template,
@@ -158,40 +164,60 @@ class ResourceManager:
158
164
  )
159
165
  return self.add_template(template)
160
166
 
161
- def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
167
+ def add_template(
168
+ self, template: ResourceTemplate, key: str | None = None
169
+ ) -> ResourceTemplate:
162
170
  """Add a template to the manager.
163
171
 
164
172
  Args:
165
173
  template: A ResourceTemplate instance to add
174
+ key: Optional URI template to use as the storage key (if different from template.uri_template)
166
175
 
167
176
  Returns:
168
177
  The added template. If a template with the same URI already exists,
169
178
  returns the existing template.
170
179
  """
180
+ uri_template_str = str(template.uri_template)
181
+ storage_key = key or uri_template_str
171
182
  logger.debug(
172
- "Adding resource",
183
+ "Adding template",
173
184
  extra={
174
- "uri": template.uri_template,
185
+ "uri_template": uri_template_str,
186
+ "storage_key": storage_key,
175
187
  "type": type(template).__name__,
176
- "resource_name": template.name,
188
+ "template_name": template.name,
177
189
  },
178
190
  )
179
- existing = self._templates.get(str(template.uri_template))
191
+ existing = self._templates.get(storage_key)
180
192
  if existing:
181
- if self.duplicate_behavior == DuplicateBehavior.WARN:
182
- logger.warning(f"Resource already exists: {template.uri_template}")
183
- self._templates[str(template.uri_template)] = template
184
- elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
185
- self._templates[str(template.uri_template)] = template
186
- elif self.duplicate_behavior == DuplicateBehavior.ERROR:
187
- raise ValueError(f"Resource already exists: {template.uri_template}")
188
- elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
189
- pass
190
- self._templates[template.uri_template] = template
193
+ if self.duplicate_behavior == "warn":
194
+ logger.warning(f"Template already exists: {storage_key}")
195
+ self._templates[storage_key] = template
196
+ elif self.duplicate_behavior == "replace":
197
+ self._templates[storage_key] = template
198
+ elif self.duplicate_behavior == "error":
199
+ raise ValueError(f"Template already exists: {storage_key}")
200
+ elif self.duplicate_behavior == "ignore":
201
+ return existing
202
+ self._templates[storage_key] = template
191
203
  return template
192
204
 
193
- async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
194
- """Get resource by URI, checking concrete resources first, then templates."""
205
+ def has_resource(self, uri: AnyUrl | str) -> bool:
206
+ """Check if a resource exists."""
207
+ uri_str = str(uri)
208
+ if uri_str in self._resources:
209
+ return True
210
+ for template_key in self._templates.keys():
211
+ if match_uri_template(uri_str, template_key):
212
+ return True
213
+ return False
214
+
215
+ async def get_resource(self, uri: AnyUrl | str) -> Resource:
216
+ """Get resource by URI, checking concrete resources first, then templates.
217
+
218
+ Raises:
219
+ NotFoundError: If no resource or template matching the URI is found.
220
+ """
195
221
  uri_str = str(uri)
196
222
  logger.debug("Getting resource", extra={"uri": uri_str})
197
223
 
@@ -199,80 +225,21 @@ class ResourceManager:
199
225
  if resource := self._resources.get(uri_str):
200
226
  return resource
201
227
 
202
- # Then check templates
203
- for template in self._templates.values():
204
- if params := template.matches(uri_str):
228
+ # Then check templates - use the utility function to match against storage keys
229
+ for storage_key, template in self._templates.items():
230
+ # Try to match against the storage key (which might be a custom key)
231
+ if params := match_uri_template(uri_str, storage_key):
205
232
  try:
206
233
  return await template.create_resource(uri_str, params)
207
234
  except Exception as e:
208
235
  raise ValueError(f"Error creating resource from template: {e}")
209
236
 
210
- raise ResourceError(f"Unknown resource: {uri}")
211
-
212
- def list_resources(self) -> list[Resource]:
213
- """List all registered resources."""
214
- logger.debug("Listing resources", extra={"count": len(self._resources)})
215
- return list(self._resources.values())
216
-
217
- def list_templates(self) -> list[ResourceTemplate]:
218
- """List all registered templates."""
219
- logger.debug("Listing templates", extra={"count": len(self._templates)})
220
- return list(self._templates.values())
237
+ raise NotFoundError(f"Unknown resource: {uri_str}")
221
238
 
222
- def import_resources(
223
- self, manager: "ResourceManager", prefix: str | None = None
224
- ) -> None:
225
- """Import resources from another resource manager.
239
+ def get_resources(self) -> dict[str, Resource]:
240
+ """Get all registered resources, keyed by URI."""
241
+ return self._resources
226
242
 
227
- Resources are imported with a prefixed URI if a prefix is provided. For example,
228
- if a resource has URI "data://users" and you import it with prefix "app+", the
229
- imported resource will have URI "app+data://users". If no prefix is provided,
230
- the original URI is used.
231
-
232
- Args:
233
- manager: The ResourceManager to import from
234
- prefix: A prefix to apply to the resource URIs, including the delimiter.
235
- For example, "app+" would result in URIs like "app+data://users".
236
- If None, the original URI is used.
237
- """
238
- for uri, resource in manager._resources.items():
239
- # Create prefixed URI and copy the resource with the new URI
240
- prefixed_uri = f"{prefix}{uri}" if prefix else uri
241
-
242
- new_resource = copy.copy(resource)
243
- new_resource.uri = AnyUrl(prefixed_uri)
244
-
245
- # Store directly in resources dictionary
246
- self.add_resource(new_resource)
247
- logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
248
-
249
- def import_templates(
250
- self, manager: "ResourceManager", prefix: str | None = None
251
- ) -> None:
252
- """Import resource templates from another resource manager.
253
-
254
- Templates are imported with a prefixed URI template if a prefix is provided.
255
- For example, if a template has URI template "data://users/{id}" and you import
256
- it with prefix "app+", the imported template will have URI template
257
- "app+data://users/{id}". If no prefix is provided, the original URI template is used.
258
-
259
- Args:
260
- manager: The ResourceManager to import templates from
261
- prefix: A prefix to apply to the template URIs, including the delimiter.
262
- For example, "app+" would result in URI templates like "app+data://users/{id}".
263
- If None, the original URI template is used.
264
- """
265
- for uri_template, template in manager._templates.items():
266
- # Create prefixed URI template and copy the template with the new URI template
267
- prefixed_uri_template = (
268
- f"{prefix}{uri_template}" if prefix else uri_template
269
- )
270
-
271
- new_template = copy.copy(template)
272
- new_template.uri_template = prefixed_uri_template
273
-
274
- # Store directly in templates dictionary
275
- self.add_template(new_template)
276
- logger.debug(
277
- f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
278
- )
243
+ def get_templates(self) -> dict[str, ResourceTemplate]:
244
+ """Get all registered templates, keyed by URI template."""
245
+ return self._templates
@@ -6,13 +6,49 @@ import inspect
6
6
  import re
7
7
  from collections.abc import Callable
8
8
  from typing import Annotated, Any
9
+ from urllib.parse import unquote
9
10
 
10
- from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
+ from mcp.types import ResourceTemplate as MCPResourceTemplate
12
+ from pydantic import (
13
+ AnyUrl,
14
+ BaseModel,
15
+ BeforeValidator,
16
+ Field,
17
+ TypeAdapter,
18
+ field_validator,
19
+ validate_call,
20
+ )
11
21
 
12
22
  from fastmcp.resources.types import FunctionResource, Resource
13
23
  from fastmcp.utilities.types import _convert_set_defaults
14
24
 
15
25
 
26
+ def build_regex(template: str) -> re.Pattern:
27
+ # Escape all non-brace characters, then restore {var} placeholders
28
+ parts = re.split(r"(\{[^}]+\})", template)
29
+ pattern = ""
30
+ for part in parts:
31
+ if part.startswith("{") and part.endswith("}"):
32
+ name = part[1:-1]
33
+ pattern += f"(?P<{name}>[^/]+)"
34
+ else:
35
+ pattern += re.escape(part)
36
+ return re.compile(f"^{pattern}$")
37
+
38
+
39
+ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
40
+ regex = build_regex(uri_template)
41
+ match = regex.match(uri)
42
+ if match:
43
+ return {k: unquote(v) for k, v in match.groupdict().items()}
44
+ return None
45
+
46
+
47
+ class MyModel(BaseModel):
48
+ key: str
49
+ value: int
50
+
51
+
16
52
  class ResourceTemplate(BaseModel):
17
53
  """A template for dynamically creating resources."""
18
54
 
@@ -32,6 +68,14 @@ class ResourceTemplate(BaseModel):
32
68
  description="JSON schema for function parameters"
33
69
  )
34
70
 
71
+ @field_validator("mime_type", mode="before")
72
+ @classmethod
73
+ def set_default_mime_type(cls, mime_type: str | None) -> str:
74
+ """Set default MIME type if not provided."""
75
+ if mime_type:
76
+ return mime_type
77
+ return "text/plain"
78
+
35
79
  @classmethod
36
80
  def from_function(
37
81
  cls,
@@ -47,6 +91,30 @@ class ResourceTemplate(BaseModel):
47
91
  if func_name == "<lambda>":
48
92
  raise ValueError("You must provide a name for lambda functions")
49
93
 
94
+ # Validate that URI params match function params
95
+ uri_params = set(re.findall(r"{(\w+)}", uri_template))
96
+ if not uri_params:
97
+ raise ValueError("URI template must contain at least one parameter")
98
+
99
+ func_params = set(inspect.signature(fn).parameters.keys())
100
+
101
+ # get the parameters that are required
102
+ required_params = {
103
+ p
104
+ for p in func_params
105
+ if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
106
+ }
107
+
108
+ if not required_params.issubset(uri_params):
109
+ raise ValueError(
110
+ f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
111
+ )
112
+
113
+ if not uri_params.issubset(func_params):
114
+ raise ValueError(
115
+ f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
116
+ )
117
+
50
118
  # Get schema from TypeAdapter - will fail if function isn't properly typed
51
119
  parameters = TypeAdapter(fn).json_schema()
52
120
 
@@ -65,12 +133,7 @@ class ResourceTemplate(BaseModel):
65
133
 
66
134
  def matches(self, uri: str) -> dict[str, Any] | None:
67
135
  """Check if URI matches template and extract parameters."""
68
- # Convert template to regex pattern
69
- pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
70
- match = re.match(f"^{pattern}$", uri)
71
- if match:
72
- return match.groupdict()
73
- return None
136
+ return match_uri_template(uri, self.uri_template)
74
137
 
75
138
  async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
76
139
  """Create a resource from the template with the given parameters."""
@@ -81,7 +144,7 @@ class ResourceTemplate(BaseModel):
81
144
  result = await result
82
145
 
83
146
  return FunctionResource(
84
- uri=uri, # type: ignore
147
+ uri=AnyUrl(uri), # Explicitly convert to AnyUrl
85
148
  name=self.name,
86
149
  description=self.description,
87
150
  mime_type=self.mime_type,
@@ -95,3 +158,13 @@ class ResourceTemplate(BaseModel):
95
158
  if not isinstance(other, ResourceTemplate):
96
159
  return False
97
160
  return self.model_dump() == other.model_dump()
161
+
162
+ def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate:
163
+ """Convert the resource template to an MCPResourceTemplate."""
164
+ kwargs = {
165
+ "uriTemplate": self.uri_template,
166
+ "name": self.name,
167
+ "description": self.description,
168
+ "mimeType": self.mime_type,
169
+ }
170
+ return MCPResourceTemplate(**kwargs | overrides)
fastmcp/server/openapi.py CHANGED
@@ -8,6 +8,7 @@ from re import Pattern
8
8
  from typing import Any, Literal
9
9
 
10
10
  import httpx
11
+ from mcp.types import TextContent
11
12
  from pydantic.networks import AnyUrl
12
13
 
13
14
  from fastmcp.resources import Resource, ResourceTemplate
@@ -613,25 +614,18 @@ class FastMCPOpenAPI(FastMCP):
613
614
  f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
614
615
  )
615
616
 
616
- async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
617
- """Override the call_tool method to return the raw result without converting to content.
617
+ async def _mcp_call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
618
+ """Override the call_tool method to return the raw result without converting to content."""
618
619
 
619
- For testing purposes, if specific tools are called, we convert the result to the expected object.
620
- """
621
620
  context = self.get_context()
622
621
  result = await self._tool_manager.call_tool(name, arguments, context=context)
623
622
 
624
- # For testing purposes, convert result to expected model based on tool name
625
- if name == "create_user_users_post":
626
- # Try to import User class from test module
627
- try:
628
- from tests.server.test_openapi import User
629
-
630
- # Convert dict to User object
631
- if isinstance(result, dict):
632
- return User(**result)
633
- except ImportError:
634
- # If User class not found, just return the raw result
635
- pass
623
+ # For other tools, ensure the response is wrapped in TextContent
624
+ if isinstance(result, dict | str):
625
+ if isinstance(result, dict):
626
+ result_text = json.dumps(result)
627
+ else:
628
+ result_text = result
629
+ return [TextContent(text=result_text, type="text")]
636
630
 
637
631
  return result