fastmcp 2.1.1__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,15 +1,18 @@
1
1
  """Resource manager functionality."""
2
2
 
3
3
  import inspect
4
- import re
5
4
  from collections.abc import Callable
6
5
  from typing import Any
7
6
 
8
7
  from pydantic import AnyUrl
9
8
 
10
- from fastmcp.exceptions import ResourceError
11
- from fastmcp.resources import FunctionResource, Resource
12
- 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
+ )
13
16
  from fastmcp.settings import DuplicateBehavior
14
17
  from fastmcp.utilities.logging import get_logger
15
18
 
@@ -19,9 +22,20 @@ logger = get_logger(__name__)
19
22
  class ResourceManager:
20
23
  """Manages FastMCP resources."""
21
24
 
22
- def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
25
+ def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
23
26
  self._resources: dict[str, Resource] = {}
24
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
+
25
39
  self.duplicate_behavior = duplicate_behavior
26
40
 
27
41
  def add_resource_or_template_from_fn(
@@ -51,7 +65,7 @@ class ResourceManager:
51
65
  has_uri_params = "{" in uri and "}" in uri
52
66
  has_func_params = bool(inspect.signature(fn).parameters)
53
67
 
54
- if has_uri_params and has_func_params:
68
+ if has_uri_params or has_func_params:
55
69
  return self.add_template_from_fn(
56
70
  fn, uri, name, description, mime_type, tags
57
71
  )
@@ -98,32 +112,35 @@ class ResourceManager:
98
112
  )
99
113
  return self.add_resource(resource)
100
114
 
101
- def add_resource(self, resource: Resource) -> Resource:
115
+ def add_resource(self, resource: Resource, key: str | None = None) -> Resource:
102
116
  """Add a resource to the manager.
103
117
 
104
118
  Args:
105
119
  resource: A Resource instance to add
120
+ key: Optional URI to use as the storage key (if different from resource.uri)
106
121
  """
122
+ storage_key = key or str(resource.uri)
107
123
  logger.debug(
108
124
  "Adding resource",
109
125
  extra={
110
126
  "uri": resource.uri,
127
+ "storage_key": storage_key,
111
128
  "type": type(resource).__name__,
112
129
  "resource_name": resource.name,
113
130
  },
114
131
  )
115
- existing = self._resources.get(str(resource.uri))
132
+ existing = self._resources.get(storage_key)
116
133
  if existing:
117
- if self.duplicate_behavior == DuplicateBehavior.WARN:
118
- logger.warning(f"Resource already exists: {resource.uri}")
119
- self._resources[str(resource.uri)] = resource
120
- elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
121
- self._resources[str(resource.uri)] = resource
122
- elif self.duplicate_behavior == DuplicateBehavior.ERROR:
123
- raise ValueError(f"Resource already exists: {resource.uri}")
124
- elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
125
- pass
126
- 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
127
144
  return resource
128
145
 
129
146
  def add_template_from_fn(
@@ -137,16 +154,6 @@ class ResourceManager:
137
154
  ) -> ResourceTemplate:
138
155
  """Create a template from a function."""
139
156
 
140
- # Validate that URI params match function params
141
- uri_params = set(re.findall(r"{(\w+)}", uri_template))
142
- func_params = set(inspect.signature(fn).parameters.keys())
143
-
144
- if uri_params != func_params:
145
- raise ValueError(
146
- f"Mismatch between URI parameters {uri_params} "
147
- f"and function parameters {func_params}"
148
- )
149
-
150
157
  template = ResourceTemplate.from_function(
151
158
  fn,
152
159
  uri_template=uri_template,
@@ -157,40 +164,60 @@ class ResourceManager:
157
164
  )
158
165
  return self.add_template(template)
159
166
 
160
- def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
167
+ def add_template(
168
+ self, template: ResourceTemplate, key: str | None = None
169
+ ) -> ResourceTemplate:
161
170
  """Add a template to the manager.
162
171
 
163
172
  Args:
164
173
  template: A ResourceTemplate instance to add
174
+ key: Optional URI template to use as the storage key (if different from template.uri_template)
165
175
 
166
176
  Returns:
167
177
  The added template. If a template with the same URI already exists,
168
178
  returns the existing template.
169
179
  """
180
+ uri_template_str = str(template.uri_template)
181
+ storage_key = key or uri_template_str
170
182
  logger.debug(
171
- "Adding resource",
183
+ "Adding template",
172
184
  extra={
173
- "uri": template.uri_template,
185
+ "uri_template": uri_template_str,
186
+ "storage_key": storage_key,
174
187
  "type": type(template).__name__,
175
- "resource_name": template.name,
188
+ "template_name": template.name,
176
189
  },
177
190
  )
178
- existing = self._templates.get(str(template.uri_template))
191
+ existing = self._templates.get(storage_key)
179
192
  if existing:
180
- if self.duplicate_behavior == DuplicateBehavior.WARN:
181
- logger.warning(f"Resource already exists: {template.uri_template}")
182
- self._templates[str(template.uri_template)] = template
183
- elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
184
- self._templates[str(template.uri_template)] = template
185
- elif self.duplicate_behavior == DuplicateBehavior.ERROR:
186
- raise ValueError(f"Resource already exists: {template.uri_template}")
187
- elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
188
- pass
189
- 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
190
203
  return template
191
204
 
192
- async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
193
- """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
+ """
194
221
  uri_str = str(uri)
195
222
  logger.debug("Getting resource", extra={"uri": uri_str})
196
223
 
@@ -198,80 +225,21 @@ class ResourceManager:
198
225
  if resource := self._resources.get(uri_str):
199
226
  return resource
200
227
 
201
- # Then check templates
202
- for template in self._templates.values():
203
- 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):
204
232
  try:
205
233
  return await template.create_resource(uri_str, params)
206
234
  except Exception as e:
207
235
  raise ValueError(f"Error creating resource from template: {e}")
208
236
 
209
- raise ResourceError(f"Unknown resource: {uri}")
210
-
211
- def list_resources(self) -> list[Resource]:
212
- """List all registered resources."""
213
- logger.debug("Listing resources", extra={"count": len(self._resources)})
214
- return list(self._resources.values())
215
-
216
- def list_templates(self) -> list[ResourceTemplate]:
217
- """List all registered templates."""
218
- logger.debug("Listing templates", extra={"count": len(self._templates)})
219
- return list(self._templates.values())
237
+ raise NotFoundError(f"Unknown resource: {uri_str}")
220
238
 
221
- def import_resources(
222
- self, manager: "ResourceManager", prefix: str | None = None
223
- ) -> None:
224
- """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
225
242
 
226
- Resources are imported with a prefixed URI if a prefix is provided. For example,
227
- if a resource has URI "data://users" and you import it with prefix "app+", the
228
- imported resource will have URI "app+data://users". If no prefix is provided,
229
- the original URI is used.
230
-
231
- Args:
232
- manager: The ResourceManager to import from
233
- prefix: A prefix to apply to the resource URIs, including the delimiter.
234
- For example, "app+" would result in URIs like "app+data://users".
235
- If None, the original URI is used.
236
- """
237
- for uri, resource in manager._resources.items():
238
- # Create prefixed URI and copy the resource with the new URI
239
- prefixed_uri = f"{prefix}{uri}" if prefix else uri
240
-
241
- new_resource = resource.copy(updates=dict(uri=prefixed_uri))
242
-
243
- # Store directly in resources dictionary
244
- self.add_resource(new_resource)
245
- logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
246
-
247
- def import_templates(
248
- self, manager: "ResourceManager", prefix: str | None = None
249
- ) -> None:
250
- """Import resource templates from another resource manager.
251
-
252
- Templates are imported with a prefixed URI template if a prefix is provided.
253
- For example, if a template has URI template "data://users/{id}" and you import
254
- it with prefix "app+", the imported template will have URI template
255
- "app+data://users/{id}". If no prefix is provided, the original URI template is used.
256
-
257
- Args:
258
- manager: The ResourceManager to import templates from
259
- prefix: A prefix to apply to the template URIs, including the delimiter.
260
- For example, "app+" would result in URI templates like "app+data://users/{id}".
261
- If None, the original URI template is used.
262
- """
263
- for uri_template, template in manager._templates.items():
264
- # Create prefixed URI template and copy the template with the new URI template
265
- prefixed_uri_template = (
266
- f"{prefix}{uri_template}" if prefix else uri_template
267
- )
268
-
269
- new_template = template.copy(
270
- updates=dict(uri_template=prefixed_uri_template)
271
- )
272
-
273
- # Store directly in templates dictionary
274
- self.add_template(new_template)
275
- logger.debug(
276
- f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
277
- )
243
+ def get_templates(self) -> dict[str, ResourceTemplate]:
244
+ """Get all registered templates, keyed by URI template."""
245
+ return self._templates
@@ -6,14 +6,49 @@ import inspect
6
6
  import re
7
7
  from collections.abc import Callable
8
8
  from typing import Annotated, Any
9
-
10
- from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
- from typing_extensions import Self
9
+ from urllib.parse import unquote
10
+
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
+ )
12
21
 
13
22
  from fastmcp.resources.types import FunctionResource, Resource
14
23
  from fastmcp.utilities.types import _convert_set_defaults
15
24
 
16
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
+
17
52
  class ResourceTemplate(BaseModel):
18
53
  """A template for dynamically creating resources."""
19
54
 
@@ -33,6 +68,14 @@ class ResourceTemplate(BaseModel):
33
68
  description="JSON schema for function parameters"
34
69
  )
35
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
+
36
79
  @classmethod
37
80
  def from_function(
38
81
  cls,
@@ -48,6 +91,30 @@ class ResourceTemplate(BaseModel):
48
91
  if func_name == "<lambda>":
49
92
  raise ValueError("You must provide a name for lambda functions")
50
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
+
51
118
  # Get schema from TypeAdapter - will fail if function isn't properly typed
52
119
  parameters = TypeAdapter(fn).json_schema()
53
120
 
@@ -66,12 +133,7 @@ class ResourceTemplate(BaseModel):
66
133
 
67
134
  def matches(self, uri: str) -> dict[str, Any] | None:
68
135
  """Check if URI matches template and extract parameters."""
69
- # Convert template to regex pattern
70
- pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
71
- match = re.match(f"^{pattern}$", uri)
72
- if match:
73
- return match.groupdict()
74
- return None
136
+ return match_uri_template(uri, self.uri_template)
75
137
 
76
138
  async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
77
139
  """Create a resource from the template with the given parameters."""
@@ -82,7 +144,7 @@ class ResourceTemplate(BaseModel):
82
144
  result = await result
83
145
 
84
146
  return FunctionResource(
85
- uri=uri, # type: ignore
147
+ uri=AnyUrl(uri), # Explicitly convert to AnyUrl
86
148
  name=self.name,
87
149
  description=self.description,
88
150
  mime_type=self.mime_type,
@@ -92,14 +154,17 @@ class ResourceTemplate(BaseModel):
92
154
  except Exception as e:
93
155
  raise ValueError(f"Error creating resource from template: {e}")
94
156
 
95
- def copy(self, updates: dict[str, Any] | None = None) -> Self:
96
- """Copy the resource template with optional updates."""
97
- data = self.model_dump()
98
- if updates:
99
- data.update(updates)
100
- return type(self)(**data)
101
-
102
157
  def __eq__(self, other: object) -> bool:
103
158
  if not isinstance(other, ResourceTemplate):
104
159
  return False
105
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