fastmcp 2.8.1__py3-none-any.whl → 2.9.1__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 +23 -7
- fastmcp/client/logging.py +1 -2
- fastmcp/client/messages.py +126 -0
- 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 +109 -13
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +27 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +44 -2
- fastmcp/server/auth/providers/bearer.py +62 -13
- fastmcp/server/context.py +113 -10
- fastmcp/server/http.py +8 -0
- fastmcp/server/low_level.py +35 -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 +446 -280
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +22 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/tools/tool_transform.py +42 -16
- 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 +75 -5
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
- fastmcp-2.9.1.dist-info/RECORD +78 -0
- fastmcp-2.8.1.dist-info/RECORD +0 -69
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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}")
|
|
426
|
+
raise e
|
|
427
|
+
|
|
428
|
+
# Handle other exceptions
|
|
429
|
+
except Exception as e:
|
|
430
|
+
logger.exception(f"Error reading resource {uri_str!r}")
|
|
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}"
|
|
449
|
+
)
|
|
450
|
+
raise e
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.exception(
|
|
453
|
+
f"Error reading resource from template {uri_str!r}"
|
|
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.")
|
fastmcp/resources/template.py
CHANGED
|
@@ -15,7 +15,7 @@ from pydantic import (
|
|
|
15
15
|
validate_call,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
from fastmcp.resources.
|
|
18
|
+
from fastmcp.resources.resource import Resource
|
|
19
19
|
from fastmcp.server.dependencies import get_context
|
|
20
20
|
from fastmcp.utilities.components import FastMCPComponent
|
|
21
21
|
from fastmcp.utilities.json_schema import compress_schema
|
|
@@ -62,6 +62,25 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
62
62
|
description="JSON schema for function parameters"
|
|
63
63
|
)
|
|
64
64
|
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
|
|
67
|
+
|
|
68
|
+
def enable(self) -> None:
|
|
69
|
+
super().enable()
|
|
70
|
+
try:
|
|
71
|
+
context = get_context()
|
|
72
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
pass # No context available
|
|
75
|
+
|
|
76
|
+
def disable(self) -> None:
|
|
77
|
+
super().disable()
|
|
78
|
+
try:
|
|
79
|
+
context = get_context()
|
|
80
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
81
|
+
except RuntimeError:
|
|
82
|
+
pass # No context available
|
|
83
|
+
|
|
65
84
|
@staticmethod
|
|
66
85
|
def from_function(
|
|
67
86
|
fn: Callable[..., Any],
|
|
@@ -128,6 +147,29 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
128
147
|
}
|
|
129
148
|
return MCPResourceTemplate(**kwargs | overrides)
|
|
130
149
|
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
|
|
152
|
+
"""Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object."""
|
|
153
|
+
# Note: This creates a simple ResourceTemplate instance. For function-based templates,
|
|
154
|
+
# the original function is lost, which is expected for remote templates.
|
|
155
|
+
return cls(
|
|
156
|
+
uri_template=mcp_template.uriTemplate,
|
|
157
|
+
name=mcp_template.name,
|
|
158
|
+
description=mcp_template.description,
|
|
159
|
+
mime_type=mcp_template.mimeType or "text/plain",
|
|
160
|
+
parameters={}, # Remote templates don't have local parameters
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def key(self) -> str:
|
|
165
|
+
"""
|
|
166
|
+
The key of the component. This is used for internal bookkeeping
|
|
167
|
+
and may reflect e.g. prefixes or other identifiers. You should not depend on
|
|
168
|
+
keys having a certain value, as the same tool loaded from different
|
|
169
|
+
hierarchies of servers may have different keys.
|
|
170
|
+
"""
|
|
171
|
+
return self._key or self.uri_template
|
|
172
|
+
|
|
131
173
|
|
|
132
174
|
class FunctionResourceTemplate(ResourceTemplate):
|
|
133
175
|
"""A template for dynamically creating resources."""
|
|
@@ -214,7 +256,7 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
214
256
|
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
|
215
257
|
)
|
|
216
258
|
|
|
217
|
-
description = description or fn
|
|
259
|
+
description = description or inspect.getdoc(fn)
|
|
218
260
|
|
|
219
261
|
# if the fn is a callable class, we need to get the __call__ method from here out
|
|
220
262
|
if not inspect.isroutine(fn):
|
|
@@ -17,13 +17,14 @@ from mcp.shared.auth import (
|
|
|
17
17
|
OAuthClientInformationFull,
|
|
18
18
|
OAuthToken,
|
|
19
19
|
)
|
|
20
|
-
from pydantic import SecretStr
|
|
20
|
+
from pydantic import AnyHttpUrl, SecretStr, ValidationError
|
|
21
21
|
|
|
22
22
|
from fastmcp.server.auth.auth import (
|
|
23
23
|
ClientRegistrationOptions,
|
|
24
24
|
OAuthProvider,
|
|
25
25
|
RevocationOptions,
|
|
26
26
|
)
|
|
27
|
+
from fastmcp.utilities.logging import get_logger
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
class JWKData(TypedDict, total=False):
|
|
@@ -89,7 +90,7 @@ class RSAKeyPair:
|
|
|
89
90
|
self,
|
|
90
91
|
subject: str = "fastmcp-user",
|
|
91
92
|
issuer: str = "https://fastmcp.example.com",
|
|
92
|
-
audience: str | None = None,
|
|
93
|
+
audience: str | list[str] | None = None,
|
|
93
94
|
scopes: list[str] | None = None,
|
|
94
95
|
expires_in_seconds: int = 3600,
|
|
95
96
|
additional_claims: dict[str, Any] | None = None,
|
|
@@ -102,7 +103,7 @@ class RSAKeyPair:
|
|
|
102
103
|
private_key_pem: RSA private key in PEM format
|
|
103
104
|
subject: Subject claim (usually user ID)
|
|
104
105
|
issuer: Issuer claim
|
|
105
|
-
audience: Audience claim (optional)
|
|
106
|
+
audience: Audience claim - can be a string or list of strings (optional)
|
|
106
107
|
scopes: List of scopes to include
|
|
107
108
|
expires_in_seconds: Token expiration time in seconds
|
|
108
109
|
additional_claims: Any additional claims to include
|
|
@@ -161,7 +162,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
161
162
|
public_key: str | None = None,
|
|
162
163
|
jwks_uri: str | None = None,
|
|
163
164
|
issuer: str | None = None,
|
|
164
|
-
audience: str | None = None,
|
|
165
|
+
audience: str | list[str] | None = None,
|
|
165
166
|
required_scopes: list[str] | None = None,
|
|
166
167
|
):
|
|
167
168
|
"""
|
|
@@ -171,7 +172,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
171
172
|
public_key: RSA public key in PEM format (for static key)
|
|
172
173
|
jwks_uri: URI to fetch keys from (for key rotation)
|
|
173
174
|
issuer: Expected issuer claim (optional)
|
|
174
|
-
audience: Expected audience claim (optional)
|
|
175
|
+
audience: Expected audience claim - can be a string or list of strings (optional)
|
|
175
176
|
required_scopes: List of required scopes for access (optional)
|
|
176
177
|
"""
|
|
177
178
|
if not (public_key or jwks_uri):
|
|
@@ -179,8 +180,16 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
179
180
|
if public_key and jwks_uri:
|
|
180
181
|
raise ValueError("Provide either public_key or jwks_uri, not both")
|
|
181
182
|
|
|
183
|
+
# Only pass issuer to parent if it's a valid URL, otherwise use default
|
|
184
|
+
# This allows the issuer claim validation to work with string issuers per RFC 7519
|
|
185
|
+
try:
|
|
186
|
+
issuer_url = AnyHttpUrl(issuer) if issuer else "https://fastmcp.example.com"
|
|
187
|
+
except ValidationError:
|
|
188
|
+
# Issuer is not a valid URL, use default for parent class
|
|
189
|
+
issuer_url = "https://fastmcp.example.com"
|
|
190
|
+
|
|
182
191
|
super().__init__(
|
|
183
|
-
issuer_url=
|
|
192
|
+
issuer_url=issuer_url,
|
|
184
193
|
client_registration_options=ClientRegistrationOptions(enabled=False),
|
|
185
194
|
revocation_options=RevocationOptions(enabled=False),
|
|
186
195
|
required_scopes=required_scopes,
|
|
@@ -191,6 +200,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
191
200
|
self.public_key = public_key
|
|
192
201
|
self.jwks_uri = jwks_uri
|
|
193
202
|
self.jwt = JsonWebToken(["RS256"])
|
|
203
|
+
self.logger = get_logger(__name__)
|
|
194
204
|
|
|
195
205
|
# Simple JWKS cache
|
|
196
206
|
self._jwks_cache: dict[str, str] = {}
|
|
@@ -257,6 +267,9 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
257
267
|
# Select the appropriate key
|
|
258
268
|
if kid:
|
|
259
269
|
if kid not in self._jwks_cache:
|
|
270
|
+
self.logger.debug(
|
|
271
|
+
"JWKS key lookup failed: key ID '%s' not found", kid
|
|
272
|
+
)
|
|
260
273
|
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
|
261
274
|
return self._jwks_cache[kid]
|
|
262
275
|
else:
|
|
@@ -271,6 +284,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
271
284
|
raise ValueError("No keys found in JWKS")
|
|
272
285
|
|
|
273
286
|
except Exception as e:
|
|
287
|
+
self.logger.debug("JWKS fetch failed: %s", str(e))
|
|
274
288
|
raise ValueError(f"Failed to fetch JWKS: {e}")
|
|
275
289
|
|
|
276
290
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
@@ -290,28 +304,61 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
290
304
|
# Decode and verify the JWT token
|
|
291
305
|
claims = self.jwt.decode(token, verification_key)
|
|
292
306
|
|
|
307
|
+
# Extract client ID early for logging
|
|
308
|
+
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
|
309
|
+
|
|
293
310
|
# Validate expiration
|
|
294
311
|
exp = claims.get("exp")
|
|
295
312
|
if exp and exp < time.time():
|
|
313
|
+
self.logger.debug(
|
|
314
|
+
"Token validation failed: expired token for client %s", client_id
|
|
315
|
+
)
|
|
316
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
296
317
|
return None
|
|
297
318
|
|
|
298
319
|
# Validate issuer - note we use issuer instead of issuer_url here because
|
|
299
320
|
# issuer is optional, allowing users to make this check optional
|
|
300
321
|
if self.issuer:
|
|
301
322
|
if claims.get("iss") != self.issuer:
|
|
323
|
+
self.logger.debug(
|
|
324
|
+
"Token validation failed: issuer mismatch for client %s",
|
|
325
|
+
client_id,
|
|
326
|
+
)
|
|
327
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
302
328
|
return None
|
|
303
329
|
|
|
304
330
|
# Validate audience if configured
|
|
305
331
|
if self.audience:
|
|
306
332
|
aud = claims.get("aud")
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
333
|
+
|
|
334
|
+
# Handle different combinations of audience types
|
|
335
|
+
audience_valid = False
|
|
336
|
+
if isinstance(self.audience, list):
|
|
337
|
+
# self.audience is a list - check if any expected audience is present
|
|
338
|
+
if isinstance(aud, list):
|
|
339
|
+
# Both are lists - check for intersection
|
|
340
|
+
audience_valid = any(
|
|
341
|
+
expected in aud for expected in self.audience
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
# aud is a string - check if it's in our expected list
|
|
345
|
+
audience_valid = aud in self.audience
|
|
346
|
+
else:
|
|
347
|
+
# self.audience is a string - use original logic
|
|
348
|
+
if isinstance(aud, list):
|
|
349
|
+
audience_valid = self.audience in aud
|
|
350
|
+
else:
|
|
351
|
+
audience_valid = aud == self.audience
|
|
352
|
+
|
|
353
|
+
if not audience_valid:
|
|
354
|
+
self.logger.debug(
|
|
355
|
+
"Token validation failed: audience mismatch for client %s",
|
|
356
|
+
client_id,
|
|
357
|
+
)
|
|
358
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
311
359
|
return None
|
|
312
360
|
|
|
313
|
-
# Extract
|
|
314
|
-
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
|
361
|
+
# Extract scopes
|
|
315
362
|
scopes = self._extract_scopes(claims)
|
|
316
363
|
|
|
317
364
|
return AccessToken(
|
|
@@ -322,8 +369,10 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
322
369
|
)
|
|
323
370
|
|
|
324
371
|
except JoseError:
|
|
372
|
+
self.logger.debug("Token validation failed: JWT signature/format invalid")
|
|
325
373
|
return None
|
|
326
|
-
except Exception:
|
|
374
|
+
except Exception as e:
|
|
375
|
+
self.logger.debug("Token validation failed: %s", str(e))
|
|
327
376
|
return None
|
|
328
377
|
|
|
329
378
|
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|