fastmcp 2.8.1__py3-none-any.whl → 2.9.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.
Files changed (38) hide show
  1. fastmcp/cli/cli.py +99 -1
  2. fastmcp/cli/run.py +1 -3
  3. fastmcp/client/auth/oauth.py +1 -2
  4. fastmcp/client/client.py +21 -5
  5. fastmcp/client/transports.py +17 -2
  6. fastmcp/contrib/mcp_mixin/README.md +79 -2
  7. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  8. fastmcp/prompts/prompt.py +91 -11
  9. fastmcp/prompts/prompt_manager.py +119 -43
  10. fastmcp/resources/resource.py +11 -1
  11. fastmcp/resources/resource_manager.py +249 -76
  12. fastmcp/resources/template.py +27 -1
  13. fastmcp/server/auth/providers/bearer.py +32 -10
  14. fastmcp/server/context.py +41 -2
  15. fastmcp/server/http.py +8 -0
  16. fastmcp/server/middleware/__init__.py +6 -0
  17. fastmcp/server/middleware/error_handling.py +206 -0
  18. fastmcp/server/middleware/logging.py +165 -0
  19. fastmcp/server/middleware/middleware.py +236 -0
  20. fastmcp/server/middleware/rate_limiting.py +231 -0
  21. fastmcp/server/middleware/timing.py +156 -0
  22. fastmcp/server/proxy.py +250 -140
  23. fastmcp/server/server.py +320 -242
  24. fastmcp/settings.py +2 -2
  25. fastmcp/tools/tool.py +6 -2
  26. fastmcp/tools/tool_manager.py +114 -45
  27. fastmcp/utilities/components.py +22 -2
  28. fastmcp/utilities/inspect.py +326 -0
  29. fastmcp/utilities/json_schema.py +67 -23
  30. fastmcp/utilities/mcp_config.py +13 -7
  31. fastmcp/utilities/openapi.py +5 -3
  32. fastmcp/utilities/tests.py +1 -1
  33. fastmcp/utilities/types.py +90 -1
  34. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/METADATA +2 -2
  35. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/RECORD +38 -31
  36. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/WHEEL +0 -0
  37. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/entry_points.txt +0 -0
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -13,7 +13,7 @@ from fastmcp.settings import DuplicateBehavior
13
13
  from fastmcp.utilities.logging import get_logger
14
14
 
15
15
  if TYPE_CHECKING:
16
- pass
16
+ from fastmcp.server.server import MountedServer
17
17
 
18
18
  logger = get_logger(__name__)
19
19
 
@@ -27,6 +27,7 @@ class PromptManager:
27
27
  mask_error_details: bool | None = None,
28
28
  ):
29
29
  self._prompts: dict[str, Prompt] = {}
30
+ self._mounted_servers: list[MountedServer] = []
30
31
  self.mask_error_details = mask_error_details or settings.mask_error_details
31
32
 
32
33
  # Default to "warn" if None is provided
@@ -41,15 +42,74 @@ class PromptManager:
41
42
 
42
43
  self.duplicate_behavior = duplicate_behavior
43
44
 
44
- def get_prompt(self, key: str) -> Prompt:
45
+ def mount(self, server: MountedServer) -> None:
46
+ """Adds a mounted server as a source for prompts."""
47
+ self._mounted_servers.append(server)
48
+
49
+ async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]:
50
+ """
51
+ The single, consolidated recursive method for fetching prompts. The 'via_server'
52
+ parameter determines the communication path.
53
+
54
+ - via_server=False: Manager-to-manager path for complete, unfiltered inventory
55
+ - via_server=True: Server-to-server path for filtered MCP requests
56
+ """
57
+ all_prompts: dict[str, Prompt] = {}
58
+
59
+ for mounted in self._mounted_servers:
60
+ try:
61
+ if via_server:
62
+ # Use the server-to-server filtered path
63
+ child_results = await mounted.server._list_prompts()
64
+ else:
65
+ # Use the manager-to-manager unfiltered path
66
+ child_results = await mounted.server._prompt_manager.list_prompts()
67
+
68
+ # The combination logic is the same for both paths
69
+ child_dict = {p.key: p for p in child_results}
70
+ if mounted.prefix:
71
+ for prompt in child_dict.values():
72
+ prefixed_prompt = prompt.with_key(
73
+ f"{mounted.prefix}_{prompt.key}"
74
+ )
75
+ all_prompts[prefixed_prompt.key] = prefixed_prompt
76
+ else:
77
+ all_prompts.update(child_dict)
78
+ except Exception as e:
79
+ # Skip failed mounts silently, matches existing behavior
80
+ logger.warning(
81
+ f"Failed to get prompts from mounted server '{mounted.prefix}': {e}"
82
+ )
83
+ continue
84
+
85
+ # Finally, add local prompts, which always take precedence
86
+ all_prompts.update(self._prompts)
87
+ return all_prompts
88
+
89
+ async def has_prompt(self, key: str) -> bool:
90
+ """Check if a prompt exists."""
91
+ prompts = await self.get_prompts()
92
+ return key in prompts
93
+
94
+ async def get_prompt(self, key: str) -> Prompt:
45
95
  """Get prompt by key."""
46
- if key in self._prompts:
47
- return self._prompts[key]
96
+ prompts = await self.get_prompts()
97
+ if key in prompts:
98
+ return prompts[key]
48
99
  raise NotFoundError(f"Unknown prompt: {key}")
49
100
 
50
- def get_prompts(self) -> dict[str, Prompt]:
51
- """Get all registered prompts, indexed by registered key."""
52
- return self._prompts
101
+ async def get_prompts(self) -> dict[str, Prompt]:
102
+ """
103
+ Gets the complete, unfiltered inventory of all prompts.
104
+ """
105
+ return await self._load_prompts(via_server=False)
106
+
107
+ async def list_prompts(self) -> list[Prompt]:
108
+ """
109
+ Lists all prompts, applying protocol filtering.
110
+ """
111
+ prompts_dict = await self._load_prompts(via_server=True)
112
+ return list(prompts_dict.values())
53
113
 
54
114
  def add_prompt_from_fn(
55
115
  self,
@@ -71,24 +131,22 @@ class PromptManager:
71
131
  )
72
132
  return self.add_prompt(prompt) # type: ignore
73
133
 
74
- def add_prompt(self, prompt: Prompt, key: str | None = None) -> Prompt:
134
+ def add_prompt(self, prompt: Prompt) -> Prompt:
75
135
  """Add a prompt to the manager."""
76
- key = key or prompt.name
77
-
78
136
  # Check for duplicates
79
- existing = self._prompts.get(key)
137
+ existing = self._prompts.get(prompt.key)
80
138
  if existing:
81
139
  if self.duplicate_behavior == "warn":
82
- logger.warning(f"Prompt already exists: {key}")
83
- self._prompts[key] = prompt
140
+ logger.warning(f"Prompt already exists: {prompt.key}")
141
+ self._prompts[prompt.key] = prompt
84
142
  elif self.duplicate_behavior == "replace":
85
- self._prompts[key] = prompt
143
+ self._prompts[prompt.key] = prompt
86
144
  elif self.duplicate_behavior == "error":
87
- raise ValueError(f"Prompt already exists: {key}")
145
+ raise ValueError(f"Prompt already exists: {prompt.key}")
88
146
  elif self.duplicate_behavior == "ignore":
89
147
  return existing
90
148
  else:
91
- self._prompts[key] = prompt
149
+ self._prompts[prompt.key] = prompt
92
150
  return prompt
93
151
 
94
152
  async def render_prompt(
@@ -96,30 +154,48 @@ class PromptManager:
96
154
  name: str,
97
155
  arguments: dict[str, Any] | None = None,
98
156
  ) -> GetPromptResult:
99
- """Render a prompt by name with arguments."""
100
- prompt = self.get_prompt(name)
101
- if not prompt:
102
- raise NotFoundError(f"Unknown prompt: {name}")
103
-
104
- try:
105
- messages = await prompt.render(arguments)
106
- return GetPromptResult(description=prompt.description, messages=messages)
107
-
108
- # Pass through PromptErrors as-is
109
- except PromptError as e:
110
- logger.exception(f"Error rendering prompt {name!r}: {e}")
111
- raise e
112
-
113
- # Handle other exceptions
114
- except Exception as e:
115
- logger.exception(f"Error rendering prompt {name!r}: {e}")
116
- if self.mask_error_details:
117
- # Mask internal details
118
- raise PromptError(f"Error rendering prompt {name!r}")
119
- else:
120
- # Include original error details
121
- raise PromptError(f"Error rendering prompt {name!r}: {e}")
122
-
123
- def has_prompt(self, key: str) -> bool:
124
- """Check if a prompt exists."""
125
- return key in self._prompts
157
+ """
158
+ Internal API for servers: Finds and renders a prompt, respecting the
159
+ filtered protocol path.
160
+ """
161
+ # 1. Check local prompts first. The server will have already applied its filter.
162
+ if name in self._prompts:
163
+ prompt = await self.get_prompt(name)
164
+ if not prompt:
165
+ raise NotFoundError(f"Unknown prompt: {name}")
166
+
167
+ try:
168
+ messages = await prompt.render(arguments)
169
+ return GetPromptResult(
170
+ description=prompt.description, messages=messages
171
+ )
172
+
173
+ # Pass through PromptErrors as-is
174
+ except PromptError as e:
175
+ logger.exception(f"Error rendering prompt {name!r}: {e}")
176
+ raise e
177
+
178
+ # Handle other exceptions
179
+ except Exception as e:
180
+ logger.exception(f"Error rendering prompt {name!r}: {e}")
181
+ if self.mask_error_details:
182
+ # Mask internal details
183
+ raise PromptError(f"Error rendering prompt {name!r}") from e
184
+ else:
185
+ # Include original error details
186
+ raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
187
+
188
+ # 2. Check mounted servers using the filtered protocol path.
189
+ for mounted in reversed(self._mounted_servers):
190
+ prompt_key = name
191
+ if mounted.prefix:
192
+ if name.startswith(f"{mounted.prefix}_"):
193
+ prompt_key = name.removeprefix(f"{mounted.prefix}_")
194
+ else:
195
+ continue
196
+ try:
197
+ return await mounted.server._get_prompt(prompt_key, arguments)
198
+ except NotFoundError:
199
+ continue
200
+
201
+ raise NotFoundError(f"Unknown prompt: {name}")
@@ -101,6 +101,16 @@ class Resource(FastMCPComponent, abc.ABC):
101
101
  def __repr__(self) -> str:
102
102
  return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
103
103
 
104
+ @property
105
+ def key(self) -> str:
106
+ """
107
+ The key of the component. This is used for internal bookkeeping
108
+ and may reflect e.g. prefixes or other identifiers. You should not depend on
109
+ keys having a certain value, as the same tool loaded from different
110
+ hierarchies of servers may have different keys.
111
+ """
112
+ return self._key or str(self.uri)
113
+
104
114
 
105
115
  class FunctionResource(Resource):
106
116
  """A resource that defers data loading by wrapping a function.
@@ -135,7 +145,7 @@ class FunctionResource(Resource):
135
145
  fn=fn,
136
146
  uri=uri,
137
147
  name=name or fn.__name__,
138
- description=description or fn.__doc__,
148
+ description=description or inspect.getdoc(fn),
139
149
  mime_type=mime_type or "text/plain",
140
150
  tags=tags or set(),
141
151
  enabled=enabled if enabled is not None else True,
@@ -1,9 +1,11 @@
1
1
  """Resource manager functionality."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import inspect
4
6
  import warnings
5
7
  from collections.abc import Callable
6
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
7
9
 
8
10
  from pydantic import AnyUrl
9
11
 
@@ -17,6 +19,9 @@ from fastmcp.resources.template import (
17
19
  from fastmcp.settings import DuplicateBehavior
18
20
  from fastmcp.utilities.logging import get_logger
19
21
 
22
+ if TYPE_CHECKING:
23
+ from fastmcp.server.server import MountedServer
24
+
20
25
  logger = get_logger(__name__)
21
26
 
22
27
 
@@ -38,6 +43,7 @@ class ResourceManager:
38
43
  """
39
44
  self._resources: dict[str, Resource] = {}
40
45
  self._templates: dict[str, ResourceTemplate] = {}
46
+ self._mounted_servers: list[MountedServer] = []
41
47
  self.mask_error_details = mask_error_details or settings.mask_error_details
42
48
 
43
49
  # Default to "warn" if None is provided
@@ -51,6 +57,128 @@ class ResourceManager:
51
57
  )
52
58
  self.duplicate_behavior = duplicate_behavior
53
59
 
60
+ def mount(self, server: MountedServer) -> None:
61
+ """Adds a mounted server as a source for resources and templates."""
62
+ self._mounted_servers.append(server)
63
+
64
+ async def get_resources(self) -> dict[str, Resource]:
65
+ """Get all registered resources, keyed by URI."""
66
+ return await self._load_resources(via_server=False)
67
+
68
+ async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
69
+ """Get all registered templates, keyed by URI template."""
70
+ return await self._load_resource_templates(via_server=False)
71
+
72
+ async def _load_resources(self, *, via_server: bool = False) -> dict[str, Resource]:
73
+ """
74
+ The single, consolidated recursive method for fetching resources. The 'via_server'
75
+ parameter determines the communication path.
76
+
77
+ - via_server=False: Manager-to-manager path for complete, unfiltered inventory
78
+ - via_server=True: Server-to-server path for filtered MCP requests
79
+ """
80
+ all_resources: dict[str, Resource] = {}
81
+
82
+ for mounted in self._mounted_servers:
83
+ try:
84
+ if via_server:
85
+ # Use the server-to-server filtered path
86
+ child_resources_list = await mounted.server._list_resources()
87
+ child_resources = {
88
+ resource.key: resource for resource in child_resources_list
89
+ }
90
+ else:
91
+ # Use the manager-to-manager unfiltered path
92
+ child_resources = (
93
+ await mounted.server._resource_manager.get_resources()
94
+ )
95
+
96
+ # Apply prefix if needed
97
+ if mounted.prefix:
98
+ from fastmcp.server.server import add_resource_prefix
99
+
100
+ for uri, resource in child_resources.items():
101
+ prefixed_uri = add_resource_prefix(
102
+ uri, mounted.prefix, mounted.resource_prefix_format
103
+ )
104
+ # Create a copy of the resource with the prefixed key
105
+ prefixed_resource = resource.with_key(prefixed_uri)
106
+ all_resources[prefixed_uri] = prefixed_resource
107
+ else:
108
+ all_resources.update(child_resources)
109
+ except Exception as e:
110
+ # Skip failed mounts silently, matches existing behavior
111
+ logger.warning(
112
+ f"Failed to get resources from mounted server '{mounted.prefix}': {e}"
113
+ )
114
+ continue
115
+
116
+ # Finally, add local resources, which always take precedence
117
+ all_resources.update(self._resources)
118
+ return all_resources
119
+
120
+ async def _load_resource_templates(
121
+ self, *, via_server: bool = False
122
+ ) -> dict[str, ResourceTemplate]:
123
+ """
124
+ The single, consolidated recursive method for fetching templates. The 'via_server'
125
+ parameter determines the communication path.
126
+
127
+ - via_server=False: Manager-to-manager path for complete, unfiltered inventory
128
+ - via_server=True: Server-to-server path for filtered MCP requests
129
+ """
130
+ all_templates: dict[str, ResourceTemplate] = {}
131
+
132
+ for mounted in self._mounted_servers:
133
+ try:
134
+ if via_server:
135
+ # Use the server-to-server filtered path
136
+ child_templates = await mounted.server._list_resource_templates()
137
+ else:
138
+ # Use the manager-to-manager unfiltered path
139
+ child_templates = (
140
+ await mounted.server._resource_manager.list_resource_templates()
141
+ )
142
+ child_dict = {template.key: template for template in child_templates}
143
+
144
+ # Apply prefix if needed
145
+ if mounted.prefix:
146
+ from fastmcp.server.server import add_resource_prefix
147
+
148
+ for uri_template, template in child_dict.items():
149
+ prefixed_uri_template = add_resource_prefix(
150
+ uri_template, mounted.prefix, mounted.resource_prefix_format
151
+ )
152
+ # Create a copy of the template with the prefixed key
153
+ prefixed_template = template.with_key(prefixed_uri_template)
154
+ all_templates[prefixed_uri_template] = prefixed_template
155
+ else:
156
+ all_templates.update(child_dict)
157
+ except Exception as e:
158
+ # Skip failed mounts silently, matches existing behavior
159
+ logger.warning(
160
+ f"Failed to get templates from mounted server '{mounted.prefix}': {e}"
161
+ )
162
+ continue
163
+
164
+ # Finally, add local templates, which always take precedence
165
+ all_templates.update(self._templates)
166
+ return all_templates
167
+
168
+ async def list_resources(self) -> list[Resource]:
169
+ """
170
+ Lists all resources, applying protocol filtering.
171
+ """
172
+ resources_dict = await self._load_resources(via_server=True)
173
+ return list(resources_dict.values())
174
+
175
+ async def list_resource_templates(self) -> list[ResourceTemplate]:
176
+ """
177
+ Lists all templates, applying protocol filtering.
178
+ """
179
+ templates_dict = await self._load_resource_templates(via_server=True)
180
+ return list(templates_dict.values())
181
+
54
182
  def add_resource_or_template_from_fn(
55
183
  self,
56
184
  fn: Callable[..., Any],
@@ -139,35 +267,26 @@ class ResourceManager:
139
267
  )
140
268
  return self.add_resource(resource)
141
269
 
142
- def add_resource(self, resource: Resource, key: str | None = None) -> Resource:
270
+ def add_resource(self, resource: Resource) -> Resource:
143
271
  """Add a resource to the manager.
144
272
 
145
273
  Args:
146
- resource: A Resource instance to add
147
- key: Optional URI to use as the storage key (if different from resource.uri)
274
+ resource: A Resource instance to add. The resource's .key attribute
275
+ will be used as the storage key. To overwrite it, call
276
+ Resource.with_key() before calling this method.
148
277
  """
149
- storage_key = key or str(resource.uri)
150
- logger.debug(
151
- "Adding resource",
152
- extra={
153
- "uri": resource.uri,
154
- "storage_key": storage_key,
155
- "type": type(resource).__name__,
156
- "resource_name": resource.name,
157
- },
158
- )
159
- existing = self._resources.get(storage_key)
278
+ existing = self._resources.get(resource.key)
160
279
  if existing:
161
280
  if self.duplicate_behavior == "warn":
162
- logger.warning(f"Resource already exists: {storage_key}")
163
- self._resources[storage_key] = resource
281
+ logger.warning(f"Resource already exists: {resource.key}")
282
+ self._resources[resource.key] = resource
164
283
  elif self.duplicate_behavior == "replace":
165
- self._resources[storage_key] = resource
284
+ self._resources[resource.key] = resource
166
285
  elif self.duplicate_behavior == "error":
167
- raise ValueError(f"Resource already exists: {storage_key}")
286
+ raise ValueError(f"Resource already exists: {resource.key}")
168
287
  elif self.duplicate_behavior == "ignore":
169
288
  return existing
170
- self._resources[storage_key] = resource
289
+ self._resources[resource.key] = resource
171
290
  return resource
172
291
 
173
292
  def add_template_from_fn(
@@ -197,52 +316,47 @@ class ResourceManager:
197
316
  )
198
317
  return self.add_template(template)
199
318
 
200
- def add_template(
201
- self, template: ResourceTemplate, key: str | None = None
202
- ) -> ResourceTemplate:
319
+ def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
203
320
  """Add a template to the manager.
204
321
 
205
322
  Args:
206
- template: A ResourceTemplate instance to add
207
- key: Optional URI template to use as the storage key (if different from template.uri_template)
323
+ template: A ResourceTemplate instance to add. The template's .key attribute
324
+ will be used as the storage key. To overwrite it, call
325
+ ResourceTemplate.with_key() before calling this method.
208
326
 
209
327
  Returns:
210
328
  The added template. If a template with the same URI already exists,
211
329
  returns the existing template.
212
330
  """
213
- uri_template_str = str(template.uri_template)
214
- storage_key = key or uri_template_str
215
- logger.debug(
216
- "Adding template",
217
- extra={
218
- "uri_template": uri_template_str,
219
- "storage_key": storage_key,
220
- "type": type(template).__name__,
221
- "template_name": template.name,
222
- },
223
- )
224
- existing = self._templates.get(storage_key)
331
+ existing = self._templates.get(template.key)
225
332
  if existing:
226
333
  if self.duplicate_behavior == "warn":
227
- logger.warning(f"Template already exists: {storage_key}")
228
- self._templates[storage_key] = template
334
+ logger.warning(f"Template already exists: {template.key}")
335
+ self._templates[template.key] = template
229
336
  elif self.duplicate_behavior == "replace":
230
- self._templates[storage_key] = template
337
+ self._templates[template.key] = template
231
338
  elif self.duplicate_behavior == "error":
232
- raise ValueError(f"Template already exists: {storage_key}")
339
+ raise ValueError(f"Template already exists: {template.key}")
233
340
  elif self.duplicate_behavior == "ignore":
234
341
  return existing
235
- self._templates[storage_key] = template
342
+ self._templates[template.key] = template
236
343
  return template
237
344
 
238
- def has_resource(self, uri: AnyUrl | str) -> bool:
345
+ async def has_resource(self, uri: AnyUrl | str) -> bool:
239
346
  """Check if a resource exists."""
240
347
  uri_str = str(uri)
241
- if uri_str in self._resources:
348
+
349
+ # First check concrete resources (local and mounted)
350
+ resources = await self.get_resources()
351
+ if uri_str in resources:
242
352
  return True
243
- for template_key in self._templates.keys():
353
+
354
+ # Then check templates (local and mounted) only if not found in concrete resources
355
+ templates = await self.get_resource_templates()
356
+ for template_key in templates.keys():
244
357
  if match_uri_template(uri_str, template_key):
245
358
  return True
359
+
246
360
  return False
247
361
 
248
362
  async def get_resource(self, uri: AnyUrl | str) -> Resource:
@@ -257,12 +371,14 @@ class ResourceManager:
257
371
  uri_str = str(uri)
258
372
  logger.debug("Getting resource", extra={"uri": uri_str})
259
373
 
260
- # First check concrete resources
261
- if resource := self._resources.get(uri_str):
374
+ # First check concrete resources (local and mounted)
375
+ resources = await self.get_resources()
376
+ if resource := resources.get(uri_str):
262
377
  return resource
263
378
 
264
- # Then check templates - use the utility function to match against storage keys
265
- for storage_key, template in self._templates.items():
379
+ # Then check templates (local and mounted) - use the utility function to match against storage keys
380
+ templates = await self.get_resource_templates()
381
+ for storage_key, template in templates.items():
266
382
  # Try to match against the storage key (which might be a custom key)
267
383
  if params := match_uri_template(uri_str, storage_key):
268
384
  try:
@@ -289,31 +405,88 @@ class ResourceManager:
289
405
  raise NotFoundError(f"Unknown resource: {uri_str}")
290
406
 
291
407
  async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
292
- """Read a resource contents."""
293
- resource = await self.get_resource(uri)
294
-
295
- try:
296
- return await resource.read()
297
-
298
- # raise ResourceErrors as-is
299
- except ResourceError as e:
300
- logger.error(f"Error reading resource {uri!r}: {e}")
301
- raise e
302
-
303
- # Handle other exceptions
304
- except Exception as e:
305
- logger.error(f"Error reading resource {uri!r}: {e}")
306
- if self.mask_error_details:
307
- # Mask internal details
308
- raise ResourceError(f"Error reading resource {uri!r}") from e
309
- else:
310
- # Include original error details
311
- raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
312
-
313
- def get_resources(self) -> dict[str, Resource]:
314
- """Get all registered resources, keyed by URI."""
315
- return self._resources
408
+ """
409
+ Internal API for servers: Finds and reads a resource, respecting the
410
+ filtered protocol path.
411
+ """
412
+ uri_str = str(uri)
316
413
 
317
- def get_templates(self) -> dict[str, ResourceTemplate]:
318
- """Get all registered templates, keyed by URI template."""
319
- return self._templates
414
+ # 1. Check local resources first. The server will have already applied its filter.
415
+ if uri_str in self._resources:
416
+ resource = await self.get_resource(uri_str)
417
+ if not resource:
418
+ raise NotFoundError(f"Resource {uri_str!r} not found")
419
+
420
+ try:
421
+ return await resource.read()
422
+
423
+ # raise ResourceErrors as-is
424
+ except ResourceError as e:
425
+ logger.exception(f"Error reading resource {uri_str!r}: {e}")
426
+ raise e
427
+
428
+ # Handle other exceptions
429
+ except Exception as e:
430
+ logger.exception(f"Error reading resource {uri_str!r}: {e}")
431
+ if self.mask_error_details:
432
+ # Mask internal details
433
+ raise ResourceError(f"Error reading resource {uri_str!r}") from e
434
+ else:
435
+ # Include original error details
436
+ raise ResourceError(
437
+ f"Error reading resource {uri_str!r}: {e}"
438
+ ) from e
439
+
440
+ # 1b. Check local templates if not found in concrete resources
441
+ for key, template in self._templates.items():
442
+ if params := match_uri_template(uri_str, key):
443
+ try:
444
+ resource = await template.create_resource(uri_str, params=params)
445
+ return await resource.read()
446
+ except ResourceError as e:
447
+ logger.exception(
448
+ f"Error reading resource from template {uri_str!r}: {e}"
449
+ )
450
+ raise e
451
+ except Exception as e:
452
+ logger.exception(
453
+ f"Error reading resource from template {uri_str!r}: {e}"
454
+ )
455
+ if self.mask_error_details:
456
+ raise ResourceError(
457
+ f"Error reading resource from template {uri_str!r}"
458
+ ) from e
459
+ else:
460
+ raise ResourceError(
461
+ f"Error reading resource from template {uri_str!r}: {e}"
462
+ ) from e
463
+
464
+ # 2. Check mounted servers using the filtered protocol path.
465
+ from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
466
+
467
+ for mounted in reversed(self._mounted_servers):
468
+ key = uri_str
469
+ try:
470
+ if mounted.prefix:
471
+ if has_resource_prefix(
472
+ key,
473
+ mounted.prefix,
474
+ mounted.resource_prefix_format,
475
+ ):
476
+ key = remove_resource_prefix(
477
+ key,
478
+ mounted.prefix,
479
+ mounted.resource_prefix_format,
480
+ )
481
+ else:
482
+ continue
483
+
484
+ try:
485
+ result = await mounted.server._read_resource(key)
486
+ return result[0].content
487
+ except NotFoundError:
488
+ continue
489
+ except NotFoundError:
490
+ continue
491
+
492
+ raise NotFoundError(f"Resource {uri_str!r} not found.")