fastmcp 2.12.5__py3-none-any.whl → 2.13.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 +7 -6
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/transports.py +77 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1256 -242
- fastmcp/server/auth/oidc_proxy.py +23 -6
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +178 -127
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +30 -9
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +32 -14
- fastmcp/server/context.py +122 -36
- fastmcp/server/http.py +58 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +683 -207
- fastmcp/settings.py +24 -10
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +62 -22
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +617 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.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,
|
|
@@ -381,12 +253,12 @@ 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)
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -161,6 +204,7 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
161
204
|
description=overrides.get("description", self.description),
|
|
162
205
|
mimeType=overrides.get("mimeType", self.mime_type),
|
|
163
206
|
title=overrides.get("title", self.title),
|
|
207
|
+
icons=overrides.get("icons", self.icons),
|
|
164
208
|
annotations=overrides.get("annotations", self.annotations),
|
|
165
209
|
_meta=overrides.get(
|
|
166
210
|
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
|
|
@@ -206,6 +250,31 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
206
250
|
if context_kwarg and context_kwarg not in kwargs:
|
|
207
251
|
kwargs[context_kwarg] = get_context()
|
|
208
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
|
+
|
|
209
278
|
result = self.fn(**kwargs)
|
|
210
279
|
if inspect.isawaitable(result):
|
|
211
280
|
result = await result
|
|
@@ -219,6 +288,7 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
219
288
|
name: str | None = None,
|
|
220
289
|
title: str | None = None,
|
|
221
290
|
description: str | None = None,
|
|
291
|
+
icons: list[Icon] | None = None,
|
|
222
292
|
mime_type: str | None = None,
|
|
223
293
|
tags: set[str] | None = None,
|
|
224
294
|
enabled: bool | None = None,
|
|
@@ -245,16 +315,19 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
245
315
|
|
|
246
316
|
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
|
247
317
|
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
251
324
|
raise ValueError("URI template must contain at least one parameter")
|
|
252
325
|
|
|
253
326
|
func_params = set(sig.parameters.keys())
|
|
254
327
|
if context_kwarg:
|
|
255
328
|
func_params.discard(context_kwarg)
|
|
256
329
|
|
|
257
|
-
#
|
|
330
|
+
# Get required and optional function parameters
|
|
258
331
|
required_params = {
|
|
259
332
|
p
|
|
260
333
|
for p in func_params
|
|
@@ -262,21 +335,37 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
262
335
|
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
|
|
263
336
|
and p != context_kwarg
|
|
264
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
|
+
)
|
|
265
354
|
|
|
266
|
-
# Check if required parameters are a subset of the
|
|
267
|
-
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):
|
|
268
357
|
raise ValueError(
|
|
269
|
-
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}"
|
|
270
359
|
)
|
|
271
360
|
|
|
272
|
-
# Check if
|
|
361
|
+
# Check if all URI parameters are valid function parameters (skip if **kwargs present)
|
|
273
362
|
if not any(
|
|
274
363
|
param.kind == inspect.Parameter.VAR_KEYWORD
|
|
275
364
|
for param in sig.parameters.values()
|
|
276
365
|
):
|
|
277
|
-
if not
|
|
366
|
+
if not all_uri_params.issubset(func_params):
|
|
278
367
|
raise ValueError(
|
|
279
|
-
f"URI parameters {
|
|
368
|
+
f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}"
|
|
280
369
|
)
|
|
281
370
|
|
|
282
371
|
description = description or inspect.getdoc(fn)
|
|
@@ -303,6 +392,7 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
|
303
392
|
name=func_name,
|
|
304
393
|
title=title,
|
|
305
394
|
description=description,
|
|
395
|
+
icons=icons,
|
|
306
396
|
mime_type=mime_type or "text/plain",
|
|
307
397
|
fn=fn,
|
|
308
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/auth/auth.py
CHANGED
|
@@ -3,10 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
|
6
|
-
from mcp.server.auth.middleware.bearer_auth import
|
|
7
|
-
BearerAuthBackend,
|
|
8
|
-
RequireAuthMiddleware,
|
|
9
|
-
)
|
|
6
|
+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
|
|
10
7
|
from mcp.server.auth.provider import (
|
|
11
8
|
AccessToken as _SDKAccessToken,
|
|
12
9
|
)
|
|
@@ -81,10 +78,10 @@ class AuthProvider(TokenVerifierProtocol):
|
|
|
81
78
|
def get_routes(
|
|
82
79
|
self,
|
|
83
80
|
mcp_path: str | None = None,
|
|
84
|
-
mcp_endpoint: Any | None = None,
|
|
85
81
|
) -> list[Route]:
|
|
86
|
-
"""Get
|
|
82
|
+
"""Get all routes for this authentication provider.
|
|
87
83
|
|
|
84
|
+
This includes both well-known discovery routes and operational routes.
|
|
88
85
|
Each provider is responsible for creating whatever routes it needs:
|
|
89
86
|
- TokenVerifier: typically no routes (default implementation)
|
|
90
87
|
- RemoteAuthProvider: protected resource metadata routes
|
|
@@ -93,30 +90,45 @@ class AuthProvider(TokenVerifierProtocol):
|
|
|
93
90
|
|
|
94
91
|
Args:
|
|
95
92
|
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
96
|
-
|
|
93
|
+
This is used to advertise the resource URL in metadata, but the
|
|
94
|
+
provider does not create the actual MCP endpoint route.
|
|
97
95
|
|
|
98
96
|
Returns:
|
|
99
|
-
List of routes for this provider
|
|
97
|
+
List of all routes for this provider (excluding the MCP endpoint itself)
|
|
100
98
|
"""
|
|
99
|
+
return []
|
|
101
100
|
|
|
102
|
-
|
|
101
|
+
def get_well_known_routes(
|
|
102
|
+
self,
|
|
103
|
+
mcp_path: str | None = None,
|
|
104
|
+
) -> list[Route]:
|
|
105
|
+
"""Get well-known discovery routes for this authentication provider.
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
resource_metadata_url = self._get_resource_url(
|
|
107
|
-
"/.well-known/oauth-protected-resource"
|
|
108
|
-
)
|
|
107
|
+
This is a utility method that filters get_routes() to return only
|
|
108
|
+
well-known discovery routes (those starting with /.well-known/).
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
mcp_endpoint, self.required_scopes, resource_metadata_url
|
|
115
|
-
),
|
|
116
|
-
)
|
|
117
|
-
)
|
|
110
|
+
Well-known routes provide OAuth metadata and discovery endpoints that
|
|
111
|
+
clients use to discover authentication capabilities. These routes should
|
|
112
|
+
be mounted at the root level of the application to comply with RFC 8414
|
|
113
|
+
and RFC 9728.
|
|
118
114
|
|
|
119
|
-
|
|
115
|
+
Common well-known routes:
|
|
116
|
+
- /.well-known/oauth-authorization-server (authorization server metadata)
|
|
117
|
+
- /.well-known/oauth-protected-resource/* (protected resource metadata)
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
121
|
+
This is used to construct path-scoped well-known URLs.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of well-known discovery routes (typically mounted at root level)
|
|
125
|
+
"""
|
|
126
|
+
all_routes = self.get_routes(mcp_path)
|
|
127
|
+
return [
|
|
128
|
+
route
|
|
129
|
+
for route in all_routes
|
|
130
|
+
if isinstance(route, Route) and route.path.startswith("/.well-known/")
|
|
131
|
+
]
|
|
120
132
|
|
|
121
133
|
def get_middleware(self) -> list:
|
|
122
134
|
"""Get HTTP application-level middleware for this auth provider.
|
|
@@ -225,14 +237,12 @@ class RemoteAuthProvider(AuthProvider):
|
|
|
225
237
|
def get_routes(
|
|
226
238
|
self,
|
|
227
239
|
mcp_path: str | None = None,
|
|
228
|
-
mcp_endpoint: Any | None = None,
|
|
229
240
|
) -> list[Route]:
|
|
230
|
-
"""Get
|
|
241
|
+
"""Get routes for this provider.
|
|
231
242
|
|
|
232
|
-
Creates protected resource metadata routes
|
|
243
|
+
Creates protected resource metadata routes (RFC 9728).
|
|
233
244
|
"""
|
|
234
|
-
|
|
235
|
-
routes = super().get_routes(mcp_path, mcp_endpoint)
|
|
245
|
+
routes = []
|
|
236
246
|
|
|
237
247
|
# Get the resource URL based on the MCP path
|
|
238
248
|
resource_url = self._get_resource_url(mcp_path)
|
|
@@ -326,14 +336,12 @@ class OAuthProvider(
|
|
|
326
336
|
def get_routes(
|
|
327
337
|
self,
|
|
328
338
|
mcp_path: str | None = None,
|
|
329
|
-
mcp_endpoint: Any | None = None,
|
|
330
339
|
) -> list[Route]:
|
|
331
340
|
"""Get OAuth authorization server routes and optional protected resource routes.
|
|
332
341
|
|
|
333
342
|
This method creates the full set of OAuth routes including:
|
|
334
343
|
- Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.)
|
|
335
344
|
- Optional protected resource routes
|
|
336
|
-
- Protected MCP endpoints if provided
|
|
337
345
|
|
|
338
346
|
Returns:
|
|
339
347
|
List of OAuth routes
|
|
@@ -366,7 +374,7 @@ class OAuthProvider(
|
|
|
366
374
|
)
|
|
367
375
|
oauth_routes.extend(protected_routes)
|
|
368
376
|
|
|
369
|
-
# Add
|
|
370
|
-
oauth_routes.extend(super().get_routes(mcp_path
|
|
377
|
+
# Add base routes
|
|
378
|
+
oauth_routes.extend(super().get_routes(mcp_path))
|
|
371
379
|
|
|
372
380
|
return oauth_routes
|