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.
- fastmcp/cli/cli.py +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +21 -5
- fastmcp/client/transports.py +17 -2
- fastmcp/contrib/mcp_mixin/README.md +79 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
- fastmcp/prompts/prompt.py +91 -11
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +11 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +27 -1
- fastmcp/server/auth/providers/bearer.py +32 -10
- fastmcp/server/context.py +41 -2
- fastmcp/server/http.py +8 -0
- fastmcp/server/middleware/__init__.py +6 -0
- fastmcp/server/middleware/error_handling.py +206 -0
- fastmcp/server/middleware/logging.py +165 -0
- fastmcp/server/middleware/middleware.py +236 -0
- fastmcp/server/middleware/rate_limiting.py +231 -0
- fastmcp/server/middleware/timing.py +156 -0
- fastmcp/server/proxy.py +250 -140
- fastmcp/server/server.py +320 -242
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +6 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/utilities/components.py +22 -2
- fastmcp/utilities/inspect.py +326 -0
- fastmcp/utilities/json_schema.py +67 -23
- fastmcp/utilities/mcp_config.py +13 -7
- fastmcp/utilities/openapi.py +5 -3
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/METADATA +2 -2
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/RECORD +38 -31
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
"""
|
|
52
|
-
|
|
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
|
|
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
|
-
"""
|
|
100
|
-
prompt
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
raise
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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}")
|
fastmcp/resources/resource.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
163
|
-
self._resources[
|
|
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[
|
|
284
|
+
self._resources[resource.key] = resource
|
|
166
285
|
elif self.duplicate_behavior == "error":
|
|
167
|
-
raise ValueError(f"Resource already exists: {
|
|
286
|
+
raise ValueError(f"Resource already exists: {resource.key}")
|
|
168
287
|
elif self.duplicate_behavior == "ignore":
|
|
169
288
|
return existing
|
|
170
|
-
self._resources[
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
228
|
-
self._templates[
|
|
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[
|
|
337
|
+
self._templates[template.key] = template
|
|
231
338
|
elif self.duplicate_behavior == "error":
|
|
232
|
-
raise ValueError(f"Template already exists: {
|
|
339
|
+
raise ValueError(f"Template already exists: {template.key}")
|
|
233
340
|
elif self.duplicate_behavior == "ignore":
|
|
234
341
|
return existing
|
|
235
|
-
self._templates[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
293
|
-
resource
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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.")
|