fastmcp 2.12.1__py3-none-any.whl → 2.13.2__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/__init__.py +2 -2
- fastmcp/cli/cli.py +56 -36
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +7 -16
- fastmcp/cli/install/claude_desktop.py +4 -12
- fastmcp/cli/install/cursor.py +20 -30
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +4 -12
- fastmcp/cli/run.py +15 -94
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +117 -206
- fastmcp/client/client.py +123 -47
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +81 -26
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +7 -7
- fastmcp/contrib/mcp_mixin/README.md +35 -4
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +16 -10
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +33 -7
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +32 -27
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +28 -20
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +119 -27
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +80 -47
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1556 -265
- fastmcp/server/auth/oidc_proxy.py +412 -0
- fastmcp/server/auth/providers/auth0.py +193 -0
- fastmcp/server/auth/providers/aws.py +263 -0
- fastmcp/server/auth/providers/azure.py +314 -129
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +229 -0
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +31 -6
- fastmcp/server/auth/providers/google.py +50 -7
- fastmcp/server/auth/providers/in_memory.py +27 -3
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +37 -15
- fastmcp/server/context.py +194 -67
- fastmcp/server/dependencies.py +56 -16
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +57 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +158 -116
- fastmcp/server/middleware/middleware.py +30 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +15 -7
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +744 -254
- fastmcp/settings.py +65 -15
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +173 -108
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +13 -11
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +21 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +182 -10
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
- fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +93 -10
- fastmcp/utilities/types.py +87 -21
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -144
- fastmcp-2.12.1.dist-info/RECORD +0 -128
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import inspect
|
|
6
6
|
import warnings
|
|
7
7
|
from collections.abc import Callable
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
from pydantic import AnyUrl
|
|
11
11
|
|
|
@@ -19,9 +19,6 @@ from fastmcp.resources.template import (
|
|
|
19
19
|
from fastmcp.settings import DuplicateBehavior
|
|
20
20
|
from fastmcp.utilities.logging import get_logger
|
|
21
21
|
|
|
22
|
-
if TYPE_CHECKING:
|
|
23
|
-
from fastmcp.server.server import MountedServer
|
|
24
|
-
|
|
25
22
|
logger = get_logger(__name__)
|
|
26
23
|
|
|
27
24
|
|
|
@@ -43,7 +40,6 @@ class ResourceManager:
|
|
|
43
40
|
"""
|
|
44
41
|
self._resources: dict[str, Resource] = {}
|
|
45
42
|
self._templates: dict[str, ResourceTemplate] = {}
|
|
46
|
-
self._mounted_servers: list[MountedServer] = []
|
|
47
43
|
self.mask_error_details = mask_error_details or settings.mask_error_details
|
|
48
44
|
|
|
49
45
|
# Default to "warn" if None is provided
|
|
@@ -57,137 +53,13 @@ class ResourceManager:
|
|
|
57
53
|
)
|
|
58
54
|
self.duplicate_behavior = duplicate_behavior
|
|
59
55
|
|
|
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
56
|
async def get_resources(self) -> dict[str, Resource]:
|
|
65
57
|
"""Get all registered resources, keyed by URI."""
|
|
66
|
-
return
|
|
58
|
+
return dict(self._resources)
|
|
67
59
|
|
|
68
60
|
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
|
69
61
|
"""Get all registered templates, keyed by URI template."""
|
|
70
|
-
return
|
|
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 and name
|
|
105
|
-
prefixed_resource = resource.model_copy(
|
|
106
|
-
update={"name": f"{mounted.prefix}_{resource.name}"},
|
|
107
|
-
key=prefixed_uri,
|
|
108
|
-
)
|
|
109
|
-
all_resources[prefixed_uri] = prefixed_resource
|
|
110
|
-
else:
|
|
111
|
-
all_resources.update(child_resources)
|
|
112
|
-
except Exception as e:
|
|
113
|
-
# Skip failed mounts silently, matches existing behavior
|
|
114
|
-
logger.warning(
|
|
115
|
-
f"Failed to get resources from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
|
|
116
|
-
)
|
|
117
|
-
if settings.mounted_components_raise_on_load_error:
|
|
118
|
-
raise
|
|
119
|
-
continue
|
|
120
|
-
|
|
121
|
-
# Finally, add local resources, which always take precedence
|
|
122
|
-
all_resources.update(self._resources)
|
|
123
|
-
return all_resources
|
|
124
|
-
|
|
125
|
-
async def _load_resource_templates(
|
|
126
|
-
self, *, via_server: bool = False
|
|
127
|
-
) -> dict[str, ResourceTemplate]:
|
|
128
|
-
"""
|
|
129
|
-
The single, consolidated recursive method for fetching templates. The 'via_server'
|
|
130
|
-
parameter determines the communication path.
|
|
131
|
-
|
|
132
|
-
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
|
133
|
-
- via_server=True: Server-to-server path for filtered MCP requests
|
|
134
|
-
"""
|
|
135
|
-
all_templates: dict[str, ResourceTemplate] = {}
|
|
136
|
-
|
|
137
|
-
for mounted in self._mounted_servers:
|
|
138
|
-
try:
|
|
139
|
-
if via_server:
|
|
140
|
-
# Use the server-to-server filtered path
|
|
141
|
-
child_templates = await mounted.server._list_resource_templates()
|
|
142
|
-
else:
|
|
143
|
-
# Use the manager-to-manager unfiltered path
|
|
144
|
-
child_templates = (
|
|
145
|
-
await mounted.server._resource_manager.list_resource_templates()
|
|
146
|
-
)
|
|
147
|
-
child_dict = {template.key: template for template in child_templates}
|
|
148
|
-
|
|
149
|
-
# Apply prefix if needed
|
|
150
|
-
if mounted.prefix:
|
|
151
|
-
from fastmcp.server.server import add_resource_prefix
|
|
152
|
-
|
|
153
|
-
for uri_template, template in child_dict.items():
|
|
154
|
-
prefixed_uri_template = add_resource_prefix(
|
|
155
|
-
uri_template, mounted.prefix, mounted.resource_prefix_format
|
|
156
|
-
)
|
|
157
|
-
# Create a copy of the template with the prefixed key and name
|
|
158
|
-
prefixed_template = template.model_copy(
|
|
159
|
-
update={"name": f"{mounted.prefix}_{template.name}"},
|
|
160
|
-
key=prefixed_uri_template,
|
|
161
|
-
)
|
|
162
|
-
all_templates[prefixed_uri_template] = prefixed_template
|
|
163
|
-
else:
|
|
164
|
-
all_templates.update(child_dict)
|
|
165
|
-
except Exception as e:
|
|
166
|
-
# Skip failed mounts silently, matches existing behavior
|
|
167
|
-
logger.warning(
|
|
168
|
-
f"Failed to get templates from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
|
|
169
|
-
)
|
|
170
|
-
if settings.mounted_components_raise_on_load_error:
|
|
171
|
-
raise
|
|
172
|
-
continue
|
|
173
|
-
|
|
174
|
-
# Finally, add local templates, which always take precedence
|
|
175
|
-
all_templates.update(self._templates)
|
|
176
|
-
return all_templates
|
|
177
|
-
|
|
178
|
-
async def list_resources(self) -> list[Resource]:
|
|
179
|
-
"""
|
|
180
|
-
Lists all resources, applying protocol filtering.
|
|
181
|
-
"""
|
|
182
|
-
resources_dict = await self._load_resources(via_server=True)
|
|
183
|
-
return list(resources_dict.values())
|
|
184
|
-
|
|
185
|
-
async def list_resource_templates(self) -> list[ResourceTemplate]:
|
|
186
|
-
"""
|
|
187
|
-
Lists all templates, applying protocol filtering.
|
|
188
|
-
"""
|
|
189
|
-
templates_dict = await self._load_resource_templates(via_server=True)
|
|
190
|
-
return list(templates_dict.values())
|
|
62
|
+
return dict(self._templates)
|
|
191
63
|
|
|
192
64
|
def add_resource_or_template_from_fn(
|
|
193
65
|
self,
|
|
@@ -363,8 +235,8 @@ class ResourceManager:
|
|
|
363
235
|
|
|
364
236
|
# Then check templates (local and mounted) only if not found in concrete resources
|
|
365
237
|
templates = await self.get_resource_templates()
|
|
366
|
-
for template_key in templates
|
|
367
|
-
if match_uri_template(uri_str, template_key):
|
|
238
|
+
for template_key in templates:
|
|
239
|
+
if match_uri_template(uri_str, template_key) is not None:
|
|
368
240
|
return True
|
|
369
241
|
|
|
370
242
|
return False
|
|
@@ -381,16 +253,16 @@ class ResourceManager:
|
|
|
381
253
|
uri_str = str(uri)
|
|
382
254
|
logger.debug("Getting resource", extra={"uri": uri_str})
|
|
383
255
|
|
|
384
|
-
# First check concrete resources
|
|
256
|
+
# First check concrete resources
|
|
385
257
|
resources = await self.get_resources()
|
|
386
258
|
if resource := resources.get(uri_str):
|
|
387
259
|
return resource
|
|
388
260
|
|
|
389
|
-
# Then check templates
|
|
261
|
+
# Then check templates
|
|
390
262
|
templates = await self.get_resource_templates()
|
|
391
263
|
for storage_key, template in templates.items():
|
|
392
264
|
# Try to match against the storage key (which might be a custom key)
|
|
393
|
-
if params := match_uri_template(uri_str, storage_key):
|
|
265
|
+
if (params := match_uri_template(uri_str, storage_key)) is not None:
|
|
394
266
|
try:
|
|
395
267
|
return await template.create_resource(
|
|
396
268
|
uri_str,
|
|
@@ -424,9 +296,6 @@ class ResourceManager:
|
|
|
424
296
|
# 1. Check local resources first. The server will have already applied its filter.
|
|
425
297
|
if uri_str in self._resources:
|
|
426
298
|
resource = await self.get_resource(uri_str)
|
|
427
|
-
if not resource:
|
|
428
|
-
raise NotFoundError(f"Resource {uri_str!r} not found")
|
|
429
|
-
|
|
430
299
|
try:
|
|
431
300
|
return await resource.read()
|
|
432
301
|
|
|
@@ -449,7 +318,7 @@ class ResourceManager:
|
|
|
449
318
|
|
|
450
319
|
# 1b. Check local templates if not found in concrete resources
|
|
451
320
|
for key, template in self._templates.items():
|
|
452
|
-
if params := match_uri_template(uri_str, key):
|
|
321
|
+
if (params := match_uri_template(uri_str, key)) is not None:
|
|
453
322
|
try:
|
|
454
323
|
resource = await template.create_resource(uri_str, params=params)
|
|
455
324
|
return await resource.read()
|
|
@@ -471,32 +340,4 @@ class ResourceManager:
|
|
|
471
340
|
f"Error reading resource from template {uri_str!r}: {e}"
|
|
472
341
|
) from e
|
|
473
342
|
|
|
474
|
-
# 2. Check mounted servers using the filtered protocol path.
|
|
475
|
-
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
|
|
476
|
-
|
|
477
|
-
for mounted in reversed(self._mounted_servers):
|
|
478
|
-
key = uri_str
|
|
479
|
-
try:
|
|
480
|
-
if mounted.prefix:
|
|
481
|
-
if has_resource_prefix(
|
|
482
|
-
key,
|
|
483
|
-
mounted.prefix,
|
|
484
|
-
mounted.resource_prefix_format,
|
|
485
|
-
):
|
|
486
|
-
key = remove_resource_prefix(
|
|
487
|
-
key,
|
|
488
|
-
mounted.prefix,
|
|
489
|
-
mounted.resource_prefix_format,
|
|
490
|
-
)
|
|
491
|
-
else:
|
|
492
|
-
continue
|
|
493
|
-
|
|
494
|
-
try:
|
|
495
|
-
result = await mounted.server._read_resource(key)
|
|
496
|
-
return result[0].content
|
|
497
|
-
except NotFoundError:
|
|
498
|
-
continue
|
|
499
|
-
except NotFoundError:
|
|
500
|
-
continue
|
|
501
|
-
|
|
502
343
|
raise NotFoundError(f"Resource {uri_str!r} not found.")
|
fastmcp/resources/template.py
CHANGED
|
@@ -6,9 +6,9 @@ import inspect
|
|
|
6
6
|
import re
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from typing import Any
|
|
9
|
-
from urllib.parse import unquote
|
|
9
|
+
from urllib.parse import parse_qs, unquote
|
|
10
10
|
|
|
11
|
-
from mcp.types import Annotations
|
|
11
|
+
from mcp.types import Annotations, Icon
|
|
12
12
|
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
13
13
|
from pydantic import (
|
|
14
14
|
Field,
|
|
@@ -26,8 +26,26 @@ from fastmcp.utilities.types import (
|
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def extract_query_params(uri_template: str) -> set[str]:
|
|
30
|
+
"""Extract query parameter names from RFC 6570 `{?param1,param2}` syntax."""
|
|
31
|
+
match = re.search(r"\{\?([^}]+)\}", uri_template)
|
|
32
|
+
if match:
|
|
33
|
+
return {p.strip() for p in match.group(1).split(",")}
|
|
34
|
+
return set()
|
|
35
|
+
|
|
36
|
+
|
|
29
37
|
def build_regex(template: str) -> re.Pattern:
|
|
30
|
-
|
|
38
|
+
"""Build regex pattern for URI template, handling RFC 6570 syntax.
|
|
39
|
+
|
|
40
|
+
Supports:
|
|
41
|
+
- `{var}` - simple path parameter
|
|
42
|
+
- `{var*}` - wildcard path parameter (captures multiple segments)
|
|
43
|
+
- `{?var1,var2}` - query parameters (ignored in path matching)
|
|
44
|
+
"""
|
|
45
|
+
# Remove query parameter syntax for path matching
|
|
46
|
+
template_without_query = re.sub(r"\{\?[^}]+\}", "", template)
|
|
47
|
+
|
|
48
|
+
parts = re.split(r"(\{[^}]+\})", template_without_query)
|
|
31
49
|
pattern = ""
|
|
32
50
|
for part in parts:
|
|
33
51
|
if part.startswith("{") and part.endswith("}"):
|
|
@@ -43,11 +61,34 @@ def build_regex(template: str) -> re.Pattern:
|
|
|
43
61
|
|
|
44
62
|
|
|
45
63
|
def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
|
|
64
|
+
"""Match URI against template and extract both path and query parameters.
|
|
65
|
+
|
|
66
|
+
Supports RFC 6570 URI templates:
|
|
67
|
+
- Path params: `{var}`, `{var*}`
|
|
68
|
+
- Query params: `{?var1,var2}`
|
|
69
|
+
"""
|
|
70
|
+
# Split URI into path and query parts
|
|
71
|
+
uri_path, _, query_string = uri.partition("?")
|
|
72
|
+
|
|
73
|
+
# Match path parameters
|
|
46
74
|
regex = build_regex(uri_template)
|
|
47
|
-
match = regex.match(
|
|
48
|
-
if match:
|
|
49
|
-
return
|
|
50
|
-
|
|
75
|
+
match = regex.match(uri_path)
|
|
76
|
+
if not match:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
params = {k: unquote(v) for k, v in match.groupdict().items()}
|
|
80
|
+
|
|
81
|
+
# Extract query parameters if present in URI and template
|
|
82
|
+
if query_string:
|
|
83
|
+
query_param_names = extract_query_params(uri_template)
|
|
84
|
+
parsed_query = parse_qs(query_string)
|
|
85
|
+
|
|
86
|
+
for name in query_param_names:
|
|
87
|
+
if name in parsed_query:
|
|
88
|
+
# Take first value if multiple provided
|
|
89
|
+
params[name] = parsed_query[name][0] # type: ignore[index]
|
|
90
|
+
|
|
91
|
+
return params
|
|
51
92
|
|
|
52
93
|
|
|
53
94
|
class ResourceTemplate(FastMCPComponent):
|
|
@@ -92,6 +133,7 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
92
133
|
name: str | None = None,
|
|
93
134
|
title: str | None = None,
|
|
94
135
|
description: str | None = None,
|
|
136
|
+
icons: list[Icon] | None = None,
|
|
95
137
|
mime_type: str | None = None,
|
|
96
138
|
tags: set[str] | None = None,
|
|
97
139
|
enabled: bool | None = None,
|
|
@@ -104,6 +146,7 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
104
146
|
name=name,
|
|
105
147
|
title=title,
|
|
106
148
|
description=description,
|
|
149
|
+
icons=icons,
|
|
107
150
|
mime_type=mime_type,
|
|
108
151
|
tags=tags,
|
|
109
152
|
enabled=enabled,
|
|
@@ -154,16 +197,19 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
154
197
|
**overrides: Any,
|
|
155
198
|
) -> MCPResourceTemplate:
|
|
156
199
|
"""Convert the resource template to an MCPResourceTemplate."""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"name"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
|
|
201
|
+
return MCPResourceTemplate(
|
|
202
|
+
name=overrides.get("name", self.name),
|
|
203
|
+
uriTemplate=overrides.get("uriTemplate", self.uri_template),
|
|
204
|
+
description=overrides.get("description", self.description),
|
|
205
|
+
mimeType=overrides.get("mimeType", self.mime_type),
|
|
206
|
+
title=overrides.get("title", self.title),
|
|
207
|
+
icons=overrides.get("icons", self.icons),
|
|
208
|
+
annotations=overrides.get("annotations", self.annotations),
|
|
209
|
+
_meta=overrides.get(
|
|
210
|
+
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
|
|
211
|
+
),
|
|
212
|
+
)
|
|
167
213
|
|
|
168
214
|
@classmethod
|
|
169
215
|
def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
|
|
@@ -204,6 +250,31 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
204
250
|
if context_kwarg and context_kwarg not in kwargs:
|
|
205
251
|
kwargs[context_kwarg] = get_context()
|
|
206
252
|
|
|
253
|
+
# Type coercion for query parameters (which arrive as strings)
|
|
254
|
+
# Get function signature for type hints
|
|
255
|
+
sig = inspect.signature(self.fn)
|
|
256
|
+
for param_name, param_value in list(kwargs.items()):
|
|
257
|
+
if param_name in sig.parameters and isinstance(param_value, str):
|
|
258
|
+
param = sig.parameters[param_name]
|
|
259
|
+
annotation = param.annotation
|
|
260
|
+
|
|
261
|
+
# Skip if no annotation or annotation is str
|
|
262
|
+
if annotation is inspect.Parameter.empty or annotation is str:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# Handle common type coercions
|
|
266
|
+
try:
|
|
267
|
+
if annotation is int:
|
|
268
|
+
kwargs[param_name] = int(param_value)
|
|
269
|
+
elif annotation is float:
|
|
270
|
+
kwargs[param_name] = float(param_value)
|
|
271
|
+
elif annotation is bool:
|
|
272
|
+
# Handle boolean strings
|
|
273
|
+
kwargs[param_name] = param_value.lower() in ("true", "1", "yes")
|
|
274
|
+
except (ValueError, AttributeError):
|
|
275
|
+
# Let validate_call handle the error
|
|
276
|
+
pass
|
|
277
|
+
|
|
207
278
|
result = self.fn(**kwargs)
|
|
208
279
|
if inspect.isawaitable(result):
|
|
209
280
|
result = await result
|
|
@@ -217,6 +288,7 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
217
288
|
name: str | None = None,
|
|
218
289
|
title: str | None = None,
|
|
219
290
|
description: str | None = None,
|
|
291
|
+
icons: list[Icon] | None = None,
|
|
220
292
|
mime_type: str | None = None,
|
|
221
293
|
tags: set[str] | None = None,
|
|
222
294
|
enabled: bool | None = None,
|
|
@@ -243,16 +315,19 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
243
315
|
|
|
244
316
|
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
|
245
317
|
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
318
|
+
# Extract path and query parameters from URI template
|
|
319
|
+
path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
|
|
320
|
+
query_params = extract_query_params(uri_template)
|
|
321
|
+
all_uri_params = path_params | query_params
|
|
322
|
+
|
|
323
|
+
if not all_uri_params:
|
|
249
324
|
raise ValueError("URI template must contain at least one parameter")
|
|
250
325
|
|
|
251
326
|
func_params = set(sig.parameters.keys())
|
|
252
327
|
if context_kwarg:
|
|
253
328
|
func_params.discard(context_kwarg)
|
|
254
329
|
|
|
255
|
-
#
|
|
330
|
+
# Get required and optional function parameters
|
|
256
331
|
required_params = {
|
|
257
332
|
p
|
|
258
333
|
for p in func_params
|
|
@@ -260,21 +335,37 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
260
335
|
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
|
|
261
336
|
and p != context_kwarg
|
|
262
337
|
}
|
|
338
|
+
optional_params = {
|
|
339
|
+
p
|
|
340
|
+
for p in func_params
|
|
341
|
+
if sig.parameters[p].default is not inspect.Parameter.empty
|
|
342
|
+
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
|
|
343
|
+
and p != context_kwarg
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# Validate RFC 6570 query parameters
|
|
347
|
+
# Query params must be optional (have defaults)
|
|
348
|
+
if query_params:
|
|
349
|
+
invalid_query_params = query_params - optional_params
|
|
350
|
+
if invalid_query_params:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"Query parameters {invalid_query_params} must be optional function parameters with default values"
|
|
353
|
+
)
|
|
263
354
|
|
|
264
|
-
# Check if required parameters are a subset of the
|
|
265
|
-
if not required_params.issubset(
|
|
355
|
+
# Check if required parameters are a subset of the path parameters
|
|
356
|
+
if not required_params.issubset(path_params):
|
|
266
357
|
raise ValueError(
|
|
267
|
-
f"Required function arguments {required_params} must be a subset of the URI parameters {
|
|
358
|
+
f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}"
|
|
268
359
|
)
|
|
269
360
|
|
|
270
|
-
# Check if
|
|
361
|
+
# Check if all URI parameters are valid function parameters (skip if **kwargs present)
|
|
271
362
|
if not any(
|
|
272
363
|
param.kind == inspect.Parameter.VAR_KEYWORD
|
|
273
364
|
for param in sig.parameters.values()
|
|
274
365
|
):
|
|
275
|
-
if not
|
|
366
|
+
if not all_uri_params.issubset(func_params):
|
|
276
367
|
raise ValueError(
|
|
277
|
-
f"URI parameters {
|
|
368
|
+
f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}"
|
|
278
369
|
)
|
|
279
370
|
|
|
280
371
|
description = description or inspect.getdoc(fn)
|
|
@@ -301,6 +392,7 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
301
392
|
name=func_name,
|
|
302
393
|
title=title,
|
|
303
394
|
description=description,
|
|
395
|
+
icons=icons,
|
|
304
396
|
mime_type=mime_type or "text/plain",
|
|
305
397
|
fn=fn,
|
|
306
398
|
parameters=parameters,
|
fastmcp/resources/types.py
CHANGED
|
@@ -5,11 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
import anyio
|
|
9
|
-
import anyio.to_thread
|
|
10
8
|
import httpx
|
|
11
9
|
import pydantic.json
|
|
10
|
+
from anyio import Path as AsyncPath
|
|
12
11
|
from pydantic import Field, ValidationInfo
|
|
12
|
+
from typing_extensions import override
|
|
13
13
|
|
|
14
14
|
from fastmcp.exceptions import ResourceError
|
|
15
15
|
from fastmcp.resources.resource import Resource
|
|
@@ -54,6 +54,10 @@ class FileResource(Resource):
|
|
|
54
54
|
description="MIME type of the resource content",
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
+
@property
|
|
58
|
+
def _async_path(self) -> AsyncPath:
|
|
59
|
+
return AsyncPath(self.path)
|
|
60
|
+
|
|
57
61
|
@pydantic.field_validator("path")
|
|
58
62
|
@classmethod
|
|
59
63
|
def validate_absolute_path(cls, path: Path) -> Path:
|
|
@@ -71,12 +75,13 @@ class FileResource(Resource):
|
|
|
71
75
|
mime_type = info.data.get("mime_type", "text/plain")
|
|
72
76
|
return not mime_type.startswith("text/")
|
|
73
77
|
|
|
78
|
+
@override
|
|
74
79
|
async def read(self) -> str | bytes:
|
|
75
80
|
"""Read the file content."""
|
|
76
81
|
try:
|
|
77
82
|
if self.is_binary:
|
|
78
|
-
return await
|
|
79
|
-
return await
|
|
83
|
+
return await self._async_path.read_bytes()
|
|
84
|
+
return await self._async_path.read_text()
|
|
80
85
|
except Exception as e:
|
|
81
86
|
raise ResourceError(f"Error reading file {self.path}") from e
|
|
82
87
|
|
|
@@ -89,11 +94,12 @@ class HttpResource(Resource):
|
|
|
89
94
|
default="application/json", description="MIME type of the resource content"
|
|
90
95
|
)
|
|
91
96
|
|
|
97
|
+
@override
|
|
92
98
|
async def read(self) -> str | bytes:
|
|
93
99
|
"""Read the HTTP content."""
|
|
94
100
|
async with httpx.AsyncClient() as client:
|
|
95
101
|
response = await client.get(self.url)
|
|
96
|
-
response.raise_for_status()
|
|
102
|
+
_ = response.raise_for_status()
|
|
97
103
|
return response.text
|
|
98
104
|
|
|
99
105
|
|
|
@@ -111,6 +117,10 @@ class DirectoryResource(Resource):
|
|
|
111
117
|
default="application/json", description="MIME type of the resource content"
|
|
112
118
|
)
|
|
113
119
|
|
|
120
|
+
@property
|
|
121
|
+
def _async_path(self) -> AsyncPath:
|
|
122
|
+
return AsyncPath(self.path)
|
|
123
|
+
|
|
114
124
|
@pydantic.field_validator("path")
|
|
115
125
|
@classmethod
|
|
116
126
|
def validate_absolute_path(cls, path: Path) -> Path:
|
|
@@ -119,33 +129,29 @@ class DirectoryResource(Resource):
|
|
|
119
129
|
raise ValueError("Path must be absolute")
|
|
120
130
|
return path
|
|
121
131
|
|
|
122
|
-
def list_files(self) -> list[Path]:
|
|
132
|
+
async def list_files(self) -> list[Path]:
|
|
123
133
|
"""List files in the directory."""
|
|
124
|
-
if not self.
|
|
134
|
+
if not await self._async_path.exists():
|
|
125
135
|
raise FileNotFoundError(f"Directory not found: {self.path}")
|
|
126
|
-
if not self.
|
|
136
|
+
if not await self._async_path.is_dir():
|
|
127
137
|
raise NotADirectoryError(f"Not a directory: {self.path}")
|
|
128
138
|
|
|
139
|
+
pattern = self.pattern or "*"
|
|
140
|
+
|
|
141
|
+
glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob
|
|
129
142
|
try:
|
|
130
|
-
if
|
|
131
|
-
return (
|
|
132
|
-
list(self.path.glob(self.pattern))
|
|
133
|
-
if not self.recursive
|
|
134
|
-
else list(self.path.rglob(self.pattern))
|
|
135
|
-
)
|
|
136
|
-
return (
|
|
137
|
-
list(self.path.glob("*"))
|
|
138
|
-
if not self.recursive
|
|
139
|
-
else list(self.path.rglob("*"))
|
|
140
|
-
)
|
|
143
|
+
return [Path(p) async for p in glob_fn(pattern) if await p.is_file()]
|
|
141
144
|
except Exception as e:
|
|
142
|
-
raise ResourceError(f"Error listing directory {self.path}
|
|
145
|
+
raise ResourceError(f"Error listing directory {self.path}") from e
|
|
143
146
|
|
|
147
|
+
@override
|
|
144
148
|
async def read(self) -> str: # Always returns JSON string
|
|
145
149
|
"""Read the directory listing."""
|
|
146
150
|
try:
|
|
147
|
-
files = await
|
|
148
|
-
|
|
151
|
+
files: list[Path] = await self.list_files()
|
|
152
|
+
|
|
153
|
+
file_list = [str(f.relative_to(self.path)) for f in files]
|
|
154
|
+
|
|
149
155
|
return json.dumps({"files": file_list}, indent=2)
|
|
150
|
-
except Exception:
|
|
151
|
-
raise ResourceError(f"Error reading directory {self.path}")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
raise ResourceError(f"Error reading directory {self.path}") from e
|
fastmcp/server/__init__.py
CHANGED
fastmcp/server/auth/__init__.py
CHANGED
|
@@ -5,19 +5,23 @@ from .auth import (
|
|
|
5
5
|
AccessToken,
|
|
6
6
|
AuthProvider,
|
|
7
7
|
)
|
|
8
|
+
from .providers.debug import DebugTokenVerifier
|
|
8
9
|
from .providers.jwt import JWTVerifier, StaticTokenVerifier
|
|
9
10
|
from .oauth_proxy import OAuthProxy
|
|
11
|
+
from .oidc_proxy import OIDCProxy
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
__all__ = [
|
|
15
|
+
"AccessToken",
|
|
13
16
|
"AuthProvider",
|
|
14
|
-
"
|
|
15
|
-
"TokenVerifier",
|
|
17
|
+
"DebugTokenVerifier",
|
|
16
18
|
"JWTVerifier",
|
|
17
|
-
"
|
|
18
|
-
"RemoteAuthProvider",
|
|
19
|
-
"AccessToken",
|
|
19
|
+
"OAuthProvider",
|
|
20
20
|
"OAuthProxy",
|
|
21
|
+
"OIDCProxy",
|
|
22
|
+
"RemoteAuthProvider",
|
|
23
|
+
"StaticTokenVerifier",
|
|
24
|
+
"TokenVerifier",
|
|
21
25
|
]
|
|
22
26
|
|
|
23
27
|
|