fastmcp 2.14.4__py3-none-any.whl → 3.0.0b1__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/_vendor/__init__.py +1 -0
- fastmcp/_vendor/docket_di/README.md +7 -0
- fastmcp/_vendor/docket_di/__init__.py +163 -0
- fastmcp/cli/cli.py +112 -28
- fastmcp/cli/install/claude_code.py +1 -5
- fastmcp/cli/install/claude_desktop.py +1 -5
- fastmcp/cli/install/cursor.py +1 -5
- fastmcp/cli/install/gemini_cli.py +1 -5
- fastmcp/cli/install/mcp_json.py +1 -6
- fastmcp/cli/run.py +146 -5
- fastmcp/client/__init__.py +7 -9
- fastmcp/client/auth/oauth.py +18 -17
- fastmcp/client/client.py +100 -870
- fastmcp/client/elicitation.py +1 -1
- fastmcp/client/mixins/__init__.py +13 -0
- fastmcp/client/mixins/prompts.py +295 -0
- fastmcp/client/mixins/resources.py +325 -0
- fastmcp/client/mixins/task_management.py +157 -0
- fastmcp/client/mixins/tools.py +397 -0
- fastmcp/client/sampling/handlers/anthropic.py +2 -2
- fastmcp/client/sampling/handlers/openai.py +1 -1
- fastmcp/client/tasks.py +3 -3
- fastmcp/client/telemetry.py +47 -0
- fastmcp/client/transports/__init__.py +38 -0
- fastmcp/client/transports/base.py +82 -0
- fastmcp/client/transports/config.py +170 -0
- fastmcp/client/transports/http.py +145 -0
- fastmcp/client/transports/inference.py +154 -0
- fastmcp/client/transports/memory.py +90 -0
- fastmcp/client/transports/sse.py +89 -0
- fastmcp/client/transports/stdio.py +543 -0
- fastmcp/contrib/component_manager/README.md +4 -10
- fastmcp/contrib/component_manager/__init__.py +1 -2
- fastmcp/contrib/component_manager/component_manager.py +95 -160
- fastmcp/contrib/component_manager/example.py +1 -1
- fastmcp/contrib/mcp_mixin/example.py +4 -4
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
- fastmcp/decorators.py +41 -0
- fastmcp/dependencies.py +12 -1
- fastmcp/exceptions.py +4 -0
- fastmcp/experimental/server/openapi/__init__.py +18 -15
- fastmcp/mcp_config.py +13 -4
- fastmcp/prompts/__init__.py +6 -3
- fastmcp/prompts/function_prompt.py +465 -0
- fastmcp/prompts/prompt.py +321 -271
- fastmcp/resources/__init__.py +5 -3
- fastmcp/resources/function_resource.py +335 -0
- fastmcp/resources/resource.py +325 -115
- fastmcp/resources/template.py +215 -43
- fastmcp/resources/types.py +27 -12
- fastmcp/server/__init__.py +2 -2
- fastmcp/server/auth/__init__.py +14 -0
- fastmcp/server/auth/auth.py +30 -10
- fastmcp/server/auth/authorization.py +190 -0
- fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
- fastmcp/server/auth/oauth_proxy/consent.py +361 -0
- fastmcp/server/auth/oauth_proxy/models.py +178 -0
- fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
- fastmcp/server/auth/oauth_proxy/ui.py +277 -0
- fastmcp/server/auth/oidc_proxy.py +2 -2
- fastmcp/server/auth/providers/auth0.py +24 -94
- fastmcp/server/auth/providers/aws.py +26 -95
- fastmcp/server/auth/providers/azure.py +41 -129
- fastmcp/server/auth/providers/descope.py +18 -49
- fastmcp/server/auth/providers/discord.py +25 -86
- fastmcp/server/auth/providers/github.py +23 -87
- fastmcp/server/auth/providers/google.py +24 -87
- fastmcp/server/auth/providers/introspection.py +60 -79
- fastmcp/server/auth/providers/jwt.py +30 -67
- fastmcp/server/auth/providers/oci.py +47 -110
- fastmcp/server/auth/providers/scalekit.py +23 -61
- fastmcp/server/auth/providers/supabase.py +18 -47
- fastmcp/server/auth/providers/workos.py +34 -127
- fastmcp/server/context.py +372 -419
- fastmcp/server/dependencies.py +541 -251
- fastmcp/server/elicitation.py +20 -18
- fastmcp/server/event_store.py +3 -3
- fastmcp/server/http.py +16 -6
- fastmcp/server/lifespan.py +198 -0
- fastmcp/server/low_level.py +92 -2
- fastmcp/server/middleware/__init__.py +5 -1
- fastmcp/server/middleware/authorization.py +312 -0
- fastmcp/server/middleware/caching.py +101 -54
- fastmcp/server/middleware/middleware.py +6 -9
- fastmcp/server/middleware/ping.py +70 -0
- fastmcp/server/middleware/tool_injection.py +2 -2
- fastmcp/server/mixins/__init__.py +7 -0
- fastmcp/server/mixins/lifespan.py +217 -0
- fastmcp/server/mixins/mcp_operations.py +392 -0
- fastmcp/server/mixins/transport.py +342 -0
- fastmcp/server/openapi/__init__.py +41 -21
- fastmcp/server/openapi/components.py +16 -339
- fastmcp/server/openapi/routing.py +34 -118
- fastmcp/server/openapi/server.py +67 -392
- fastmcp/server/providers/__init__.py +71 -0
- fastmcp/server/providers/aggregate.py +261 -0
- fastmcp/server/providers/base.py +578 -0
- fastmcp/server/providers/fastmcp_provider.py +674 -0
- fastmcp/server/providers/filesystem.py +226 -0
- fastmcp/server/providers/filesystem_discovery.py +327 -0
- fastmcp/server/providers/local_provider/__init__.py +11 -0
- fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
- fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
- fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
- fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
- fastmcp/server/providers/local_provider/local_provider.py +465 -0
- fastmcp/server/providers/openapi/__init__.py +39 -0
- fastmcp/server/providers/openapi/components.py +332 -0
- fastmcp/server/providers/openapi/provider.py +405 -0
- fastmcp/server/providers/openapi/routing.py +109 -0
- fastmcp/server/providers/proxy.py +867 -0
- fastmcp/server/providers/skills/__init__.py +59 -0
- fastmcp/server/providers/skills/_common.py +101 -0
- fastmcp/server/providers/skills/claude_provider.py +44 -0
- fastmcp/server/providers/skills/directory_provider.py +153 -0
- fastmcp/server/providers/skills/skill_provider.py +432 -0
- fastmcp/server/providers/skills/vendor_providers.py +142 -0
- fastmcp/server/providers/wrapped_provider.py +140 -0
- fastmcp/server/proxy.py +34 -700
- fastmcp/server/sampling/run.py +341 -2
- fastmcp/server/sampling/sampling_tool.py +4 -3
- fastmcp/server/server.py +1214 -2171
- fastmcp/server/tasks/__init__.py +2 -1
- fastmcp/server/tasks/capabilities.py +13 -1
- fastmcp/server/tasks/config.py +66 -3
- fastmcp/server/tasks/handlers.py +65 -273
- fastmcp/server/tasks/keys.py +4 -6
- fastmcp/server/tasks/requests.py +474 -0
- fastmcp/server/tasks/routing.py +76 -0
- fastmcp/server/tasks/subscriptions.py +20 -11
- fastmcp/server/telemetry.py +131 -0
- fastmcp/server/transforms/__init__.py +244 -0
- fastmcp/server/transforms/namespace.py +193 -0
- fastmcp/server/transforms/prompts_as_tools.py +175 -0
- fastmcp/server/transforms/resources_as_tools.py +190 -0
- fastmcp/server/transforms/tool_transform.py +96 -0
- fastmcp/server/transforms/version_filter.py +124 -0
- fastmcp/server/transforms/visibility.py +526 -0
- fastmcp/settings.py +34 -96
- fastmcp/telemetry.py +122 -0
- fastmcp/tools/__init__.py +10 -3
- fastmcp/tools/function_parsing.py +201 -0
- fastmcp/tools/function_tool.py +467 -0
- fastmcp/tools/tool.py +215 -362
- fastmcp/tools/tool_transform.py +38 -21
- fastmcp/utilities/async_utils.py +69 -0
- fastmcp/utilities/components.py +152 -91
- fastmcp/utilities/inspect.py +8 -20
- fastmcp/utilities/json_schema.py +12 -5
- fastmcp/utilities/json_schema_type.py +17 -15
- fastmcp/utilities/lifespan.py +56 -0
- fastmcp/utilities/logging.py +12 -4
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/openapi/parser.py +3 -3
- fastmcp/utilities/pagination.py +80 -0
- fastmcp/utilities/skills.py +253 -0
- fastmcp/utilities/tests.py +0 -16
- fastmcp/utilities/timeout.py +47 -0
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/versions.py +285 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
- fastmcp-3.0.0b1.dist-info/RECORD +228 -0
- fastmcp/client/transports.py +0 -1170
- fastmcp/contrib/component_manager/component_service.py +0 -209
- fastmcp/prompts/prompt_manager.py +0 -117
- fastmcp/resources/resource_manager.py +0 -338
- fastmcp/server/tasks/converters.py +0 -206
- fastmcp/server/tasks/protocol.py +0 -359
- fastmcp/tools/tool_manager.py +0 -170
- fastmcp/utilities/mcp_config.py +0 -56
- fastmcp-2.14.4.dist-info/RECORD +0 -161
- /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Authorization middleware for FastMCP.
|
|
2
|
+
|
|
3
|
+
This module provides middleware-based authorization using callable auth checks.
|
|
4
|
+
AuthMiddleware applies auth checks globally to all components on the server.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
```python
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from fastmcp.server.auth import require_auth, require_scopes, restrict_tag
|
|
10
|
+
from fastmcp.server.middleware import AuthMiddleware
|
|
11
|
+
|
|
12
|
+
# Require auth for all components
|
|
13
|
+
mcp = FastMCP(middleware=[
|
|
14
|
+
AuthMiddleware(auth=require_auth)
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
# Tag-based: components tagged "admin" require "admin" scope
|
|
18
|
+
mcp = FastMCP(middleware=[
|
|
19
|
+
AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"]))
|
|
20
|
+
])
|
|
21
|
+
```
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
import mcp.types as mt
|
|
30
|
+
|
|
31
|
+
from fastmcp.exceptions import AuthorizationError
|
|
32
|
+
from fastmcp.prompts.prompt import Prompt, PromptResult
|
|
33
|
+
from fastmcp.resources.resource import Resource, ResourceResult
|
|
34
|
+
from fastmcp.resources.template import ResourceTemplate
|
|
35
|
+
from fastmcp.server.auth.authorization import (
|
|
36
|
+
AuthCheck,
|
|
37
|
+
AuthContext,
|
|
38
|
+
run_auth_checks,
|
|
39
|
+
)
|
|
40
|
+
from fastmcp.server.dependencies import get_access_token
|
|
41
|
+
from fastmcp.server.middleware.middleware import (
|
|
42
|
+
CallNext,
|
|
43
|
+
Middleware,
|
|
44
|
+
MiddlewareContext,
|
|
45
|
+
)
|
|
46
|
+
from fastmcp.tools.tool import Tool, ToolResult
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthMiddleware(Middleware):
|
|
52
|
+
"""Global authorization middleware using callable checks.
|
|
53
|
+
|
|
54
|
+
This middleware applies auth checks to all components (tools, resources,
|
|
55
|
+
prompts) on the server. It uses the same callable API as component-level
|
|
56
|
+
auth checks.
|
|
57
|
+
|
|
58
|
+
The middleware:
|
|
59
|
+
- Filters tools/resources/prompts from list responses based on auth checks
|
|
60
|
+
- Checks auth before tool execution, resource read, and prompt render
|
|
61
|
+
- Skips all auth checks for STDIO transport (no OAuth concept)
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
auth: A single auth check function or list of check functions.
|
|
65
|
+
All checks must pass for authorization to succeed (AND logic).
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
```python
|
|
69
|
+
from fastmcp import FastMCP
|
|
70
|
+
from fastmcp.server.auth import require_auth, require_scopes
|
|
71
|
+
|
|
72
|
+
# Require any authentication for all components
|
|
73
|
+
mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)])
|
|
74
|
+
|
|
75
|
+
# Require specific scope for all components
|
|
76
|
+
mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))])
|
|
77
|
+
|
|
78
|
+
# Combined checks (AND logic)
|
|
79
|
+
mcp = FastMCP(middleware=[
|
|
80
|
+
AuthMiddleware(auth=[require_auth, require_scopes("api")])
|
|
81
|
+
])
|
|
82
|
+
```
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, auth: AuthCheck | list[AuthCheck]) -> None:
|
|
86
|
+
self.auth = auth
|
|
87
|
+
|
|
88
|
+
async def on_list_tools(
|
|
89
|
+
self,
|
|
90
|
+
context: MiddlewareContext[mt.ListToolsRequest],
|
|
91
|
+
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
|
|
92
|
+
) -> Sequence[Tool]:
|
|
93
|
+
"""Filter tools/list response based on auth checks."""
|
|
94
|
+
tools = await call_next(context)
|
|
95
|
+
|
|
96
|
+
# STDIO has no auth concept, skip filtering
|
|
97
|
+
# Late import to avoid circular import with context.py
|
|
98
|
+
from fastmcp.server.context import _current_transport
|
|
99
|
+
|
|
100
|
+
if _current_transport.get() == "stdio":
|
|
101
|
+
return tools
|
|
102
|
+
|
|
103
|
+
token = get_access_token()
|
|
104
|
+
|
|
105
|
+
authorized_tools: list[Tool] = []
|
|
106
|
+
for tool in tools:
|
|
107
|
+
ctx = AuthContext(token=token, component=tool)
|
|
108
|
+
if run_auth_checks(self.auth, ctx):
|
|
109
|
+
authorized_tools.append(tool)
|
|
110
|
+
|
|
111
|
+
return authorized_tools
|
|
112
|
+
|
|
113
|
+
async def on_call_tool(
|
|
114
|
+
self,
|
|
115
|
+
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
116
|
+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
117
|
+
) -> ToolResult:
|
|
118
|
+
"""Check auth before tool execution."""
|
|
119
|
+
# STDIO has no auth concept, skip enforcement
|
|
120
|
+
# Late import to avoid circular import with context.py
|
|
121
|
+
from fastmcp.server.context import _current_transport
|
|
122
|
+
|
|
123
|
+
if _current_transport.get() == "stdio":
|
|
124
|
+
return await call_next(context)
|
|
125
|
+
|
|
126
|
+
# Get the tool being called
|
|
127
|
+
tool_name = context.message.name
|
|
128
|
+
fastmcp = context.fastmcp_context
|
|
129
|
+
if fastmcp is None:
|
|
130
|
+
# Fail closed: deny access when context is missing
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"AuthMiddleware: fastmcp_context is None for tool '{tool_name}'. "
|
|
133
|
+
"Denying access for security."
|
|
134
|
+
)
|
|
135
|
+
raise AuthorizationError(
|
|
136
|
+
f"Authorization failed for tool '{tool_name}': missing context"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Get tool (component auth is checked in get_tool, raises if unauthorized)
|
|
140
|
+
tool = await fastmcp.fastmcp.get_tool(tool_name)
|
|
141
|
+
if tool is None:
|
|
142
|
+
raise AuthorizationError(
|
|
143
|
+
f"Authorization failed for tool '{tool_name}': tool not found"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Global auth check
|
|
147
|
+
token = get_access_token()
|
|
148
|
+
ctx = AuthContext(token=token, component=tool)
|
|
149
|
+
if not run_auth_checks(self.auth, ctx):
|
|
150
|
+
raise AuthorizationError(
|
|
151
|
+
f"Authorization failed for tool '{tool_name}': insufficient permissions"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return await call_next(context)
|
|
155
|
+
|
|
156
|
+
async def on_list_resources(
|
|
157
|
+
self,
|
|
158
|
+
context: MiddlewareContext[mt.ListResourcesRequest],
|
|
159
|
+
call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]],
|
|
160
|
+
) -> Sequence[Resource]:
|
|
161
|
+
"""Filter resources/list response based on auth checks."""
|
|
162
|
+
resources = await call_next(context)
|
|
163
|
+
|
|
164
|
+
# STDIO has no auth concept, skip filtering
|
|
165
|
+
from fastmcp.server.context import _current_transport
|
|
166
|
+
|
|
167
|
+
if _current_transport.get() == "stdio":
|
|
168
|
+
return resources
|
|
169
|
+
|
|
170
|
+
token = get_access_token()
|
|
171
|
+
|
|
172
|
+
authorized_resources: list[Resource] = []
|
|
173
|
+
for resource in resources:
|
|
174
|
+
ctx = AuthContext(token=token, component=resource)
|
|
175
|
+
if run_auth_checks(self.auth, ctx):
|
|
176
|
+
authorized_resources.append(resource)
|
|
177
|
+
|
|
178
|
+
return authorized_resources
|
|
179
|
+
|
|
180
|
+
async def on_read_resource(
|
|
181
|
+
self,
|
|
182
|
+
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
183
|
+
call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult],
|
|
184
|
+
) -> ResourceResult:
|
|
185
|
+
"""Check auth before resource read."""
|
|
186
|
+
# STDIO has no auth concept, skip enforcement
|
|
187
|
+
from fastmcp.server.context import _current_transport
|
|
188
|
+
|
|
189
|
+
if _current_transport.get() == "stdio":
|
|
190
|
+
return await call_next(context)
|
|
191
|
+
|
|
192
|
+
# Get the resource being read
|
|
193
|
+
uri = context.message.uri
|
|
194
|
+
fastmcp = context.fastmcp_context
|
|
195
|
+
if fastmcp is None:
|
|
196
|
+
logger.warning(
|
|
197
|
+
f"AuthMiddleware: fastmcp_context is None for resource '{uri}'. "
|
|
198
|
+
"Denying access for security."
|
|
199
|
+
)
|
|
200
|
+
raise AuthorizationError(
|
|
201
|
+
f"Authorization failed for resource '{uri}': missing context"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Get resource/template (component auth is checked in get_*, raises if unauthorized)
|
|
205
|
+
component = await fastmcp.fastmcp.get_resource(str(uri))
|
|
206
|
+
if component is None:
|
|
207
|
+
component = await fastmcp.fastmcp.get_resource_template(str(uri))
|
|
208
|
+
if component is None:
|
|
209
|
+
raise AuthorizationError(
|
|
210
|
+
f"Authorization failed for resource '{uri}': resource not found"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Global auth check
|
|
214
|
+
token = get_access_token()
|
|
215
|
+
ctx = AuthContext(token=token, component=component)
|
|
216
|
+
if not run_auth_checks(self.auth, ctx):
|
|
217
|
+
raise AuthorizationError(
|
|
218
|
+
f"Authorization failed for resource '{uri}': insufficient permissions"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return await call_next(context)
|
|
222
|
+
|
|
223
|
+
async def on_list_resource_templates(
|
|
224
|
+
self,
|
|
225
|
+
context: MiddlewareContext[mt.ListResourceTemplatesRequest],
|
|
226
|
+
call_next: CallNext[
|
|
227
|
+
mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]
|
|
228
|
+
],
|
|
229
|
+
) -> Sequence[ResourceTemplate]:
|
|
230
|
+
"""Filter resource templates/list response based on auth checks."""
|
|
231
|
+
templates = await call_next(context)
|
|
232
|
+
|
|
233
|
+
# STDIO has no auth concept, skip filtering
|
|
234
|
+
from fastmcp.server.context import _current_transport
|
|
235
|
+
|
|
236
|
+
if _current_transport.get() == "stdio":
|
|
237
|
+
return templates
|
|
238
|
+
|
|
239
|
+
token = get_access_token()
|
|
240
|
+
|
|
241
|
+
authorized_templates: list[ResourceTemplate] = []
|
|
242
|
+
for template in templates:
|
|
243
|
+
ctx = AuthContext(token=token, component=template)
|
|
244
|
+
if run_auth_checks(self.auth, ctx):
|
|
245
|
+
authorized_templates.append(template)
|
|
246
|
+
|
|
247
|
+
return authorized_templates
|
|
248
|
+
|
|
249
|
+
async def on_list_prompts(
|
|
250
|
+
self,
|
|
251
|
+
context: MiddlewareContext[mt.ListPromptsRequest],
|
|
252
|
+
call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]],
|
|
253
|
+
) -> Sequence[Prompt]:
|
|
254
|
+
"""Filter prompts/list response based on auth checks."""
|
|
255
|
+
prompts = await call_next(context)
|
|
256
|
+
|
|
257
|
+
# STDIO has no auth concept, skip filtering
|
|
258
|
+
from fastmcp.server.context import _current_transport
|
|
259
|
+
|
|
260
|
+
if _current_transport.get() == "stdio":
|
|
261
|
+
return prompts
|
|
262
|
+
|
|
263
|
+
token = get_access_token()
|
|
264
|
+
|
|
265
|
+
authorized_prompts: list[Prompt] = []
|
|
266
|
+
for prompt in prompts:
|
|
267
|
+
ctx = AuthContext(token=token, component=prompt)
|
|
268
|
+
if run_auth_checks(self.auth, ctx):
|
|
269
|
+
authorized_prompts.append(prompt)
|
|
270
|
+
|
|
271
|
+
return authorized_prompts
|
|
272
|
+
|
|
273
|
+
async def on_get_prompt(
|
|
274
|
+
self,
|
|
275
|
+
context: MiddlewareContext[mt.GetPromptRequestParams],
|
|
276
|
+
call_next: CallNext[mt.GetPromptRequestParams, PromptResult],
|
|
277
|
+
) -> PromptResult:
|
|
278
|
+
"""Check auth before prompt render."""
|
|
279
|
+
# STDIO has no auth concept, skip enforcement
|
|
280
|
+
from fastmcp.server.context import _current_transport
|
|
281
|
+
|
|
282
|
+
if _current_transport.get() == "stdio":
|
|
283
|
+
return await call_next(context)
|
|
284
|
+
|
|
285
|
+
# Get the prompt being rendered
|
|
286
|
+
prompt_name = context.message.name
|
|
287
|
+
fastmcp = context.fastmcp_context
|
|
288
|
+
if fastmcp is None:
|
|
289
|
+
logger.warning(
|
|
290
|
+
f"AuthMiddleware: fastmcp_context is None for prompt '{prompt_name}'. "
|
|
291
|
+
"Denying access for security."
|
|
292
|
+
)
|
|
293
|
+
raise AuthorizationError(
|
|
294
|
+
f"Authorization failed for prompt '{prompt_name}': missing context"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Get prompt (component auth is checked in get_prompt, raises if unauthorized)
|
|
298
|
+
prompt = await fastmcp.fastmcp.get_prompt(prompt_name)
|
|
299
|
+
if prompt is None:
|
|
300
|
+
raise AuthorizationError(
|
|
301
|
+
f"Authorization failed for prompt '{prompt_name}': prompt not found"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Global auth check
|
|
305
|
+
token = get_access_token()
|
|
306
|
+
ctx = AuthContext(token=token, component=prompt)
|
|
307
|
+
if not run_auth_checks(self.auth, ctx):
|
|
308
|
+
raise AuthorizationError(
|
|
309
|
+
f"Authorization failed for prompt '{prompt_name}': insufficient permissions"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return await call_next(context)
|
|
@@ -14,15 +14,15 @@ from key_value.aio.wrappers.statistics import StatisticsWrapper
|
|
|
14
14
|
from key_value.aio.wrappers.statistics.wrapper import (
|
|
15
15
|
KVStoreCollectionStatistics,
|
|
16
16
|
)
|
|
17
|
-
from
|
|
18
|
-
from pydantic import BaseModel, Field
|
|
17
|
+
from pydantic import Field
|
|
19
18
|
from typing_extensions import NotRequired, Self, override
|
|
20
19
|
|
|
21
|
-
from fastmcp.prompts.prompt import Prompt
|
|
22
|
-
from fastmcp.resources.resource import Resource
|
|
20
|
+
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
|
21
|
+
from fastmcp.resources.resource import Resource, ResourceContent, ResourceResult
|
|
23
22
|
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
|
|
24
23
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
25
24
|
from fastmcp.utilities.logging import get_logger
|
|
25
|
+
from fastmcp.utilities.types import FastMCPBaseModel
|
|
26
26
|
|
|
27
27
|
logger: Logger = get_logger(name=__name__)
|
|
28
28
|
|
|
@@ -35,32 +35,48 @@ ONE_MB_IN_BYTES = 1024 * 1024
|
|
|
35
35
|
GLOBAL_KEY = "__global__"
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
class
|
|
39
|
-
"""A wrapper for
|
|
38
|
+
class CachableResourceContent(FastMCPBaseModel):
|
|
39
|
+
"""A wrapper for ResourceContent that can be cached."""
|
|
40
40
|
|
|
41
41
|
content: str | bytes
|
|
42
42
|
mime_type: str | None = None
|
|
43
|
+
meta: dict[str, Any] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CachableResourceResult(FastMCPBaseModel):
|
|
47
|
+
"""A wrapper for ResourceResult that can be cached."""
|
|
48
|
+
|
|
49
|
+
contents: list[CachableResourceContent]
|
|
50
|
+
meta: dict[str, Any] | None = None
|
|
43
51
|
|
|
44
52
|
def get_size(self) -> int:
|
|
45
53
|
return len(self.model_dump_json())
|
|
46
54
|
|
|
47
55
|
@classmethod
|
|
48
|
-
def
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
def wrap(cls, value: ResourceResult) -> Self:
|
|
57
|
+
return cls(
|
|
58
|
+
contents=[
|
|
59
|
+
CachableResourceContent(
|
|
60
|
+
content=item.content, mime_type=item.mime_type, meta=item.meta
|
|
61
|
+
)
|
|
62
|
+
for item in value.contents
|
|
63
|
+
],
|
|
64
|
+
meta=value.meta,
|
|
65
|
+
)
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
def unwrap(self) -> ResourceResult:
|
|
68
|
+
return ResourceResult(
|
|
69
|
+
contents=[
|
|
70
|
+
ResourceContent(
|
|
71
|
+
content=item.content, mime_type=item.mime_type, meta=item.meta
|
|
72
|
+
)
|
|
73
|
+
for item in self.contents
|
|
74
|
+
],
|
|
75
|
+
meta=self.meta,
|
|
76
|
+
)
|
|
61
77
|
|
|
62
78
|
|
|
63
|
-
class CachableToolResult(
|
|
79
|
+
class CachableToolResult(FastMCPBaseModel):
|
|
64
80
|
content: list[mcp.types.ContentBlock]
|
|
65
81
|
structured_content: dict[str, Any] | None
|
|
66
82
|
meta: dict[str, Any] | None
|
|
@@ -81,6 +97,44 @@ class CachableToolResult(BaseModel):
|
|
|
81
97
|
)
|
|
82
98
|
|
|
83
99
|
|
|
100
|
+
class CachableMessage(FastMCPBaseModel):
|
|
101
|
+
"""A wrapper for Message that can be cached."""
|
|
102
|
+
|
|
103
|
+
role: str
|
|
104
|
+
content: mcp.types.TextContent | mcp.types.EmbeddedResource
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CachablePromptResult(FastMCPBaseModel):
|
|
108
|
+
"""A wrapper for PromptResult that can be cached."""
|
|
109
|
+
|
|
110
|
+
messages: list[CachableMessage]
|
|
111
|
+
description: str | None = None
|
|
112
|
+
meta: dict[str, Any] | None = None
|
|
113
|
+
|
|
114
|
+
def get_size(self) -> int:
|
|
115
|
+
return len(self.model_dump_json())
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def wrap(cls, value: PromptResult) -> Self:
|
|
119
|
+
return cls(
|
|
120
|
+
messages=[
|
|
121
|
+
CachableMessage(role=m.role, content=m.content) for m in value.messages
|
|
122
|
+
],
|
|
123
|
+
description=value.description,
|
|
124
|
+
meta=value.meta,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def unwrap(self) -> PromptResult:
|
|
128
|
+
return PromptResult(
|
|
129
|
+
messages=[
|
|
130
|
+
Message(content=m.content, role=m.role) # type: ignore[arg-type]
|
|
131
|
+
for m in self.messages
|
|
132
|
+
],
|
|
133
|
+
description=self.description,
|
|
134
|
+
meta=self.meta,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
84
138
|
class SharedMethodSettings(TypedDict):
|
|
85
139
|
"""Shared config for a cache method."""
|
|
86
140
|
|
|
@@ -115,7 +169,7 @@ class GetPromptSettings(SharedMethodSettings):
|
|
|
115
169
|
"""Configuration options for Prompt-related caching."""
|
|
116
170
|
|
|
117
171
|
|
|
118
|
-
class ResponseCachingStatistics(
|
|
172
|
+
class ResponseCachingStatistics(FastMCPBaseModel):
|
|
119
173
|
list_tools: KVStoreCollectionStatistics | None = Field(default=None)
|
|
120
174
|
list_resources: KVStoreCollectionStatistics | None = Field(default=None)
|
|
121
175
|
list_prompts: KVStoreCollectionStatistics | None = Field(default=None)
|
|
@@ -189,43 +243,43 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
189
243
|
call_tool_settings or CallToolSettings()
|
|
190
244
|
)
|
|
191
245
|
|
|
246
|
+
# PydanticAdapter type signature will be fixed to accept generic aliases
|
|
247
|
+
# See: https://github.com/strawgate/py-key-value/pull/250
|
|
192
248
|
self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter(
|
|
193
249
|
key_value=self._stats,
|
|
194
|
-
pydantic_model=list[Tool],
|
|
250
|
+
pydantic_model=list[Tool], # type: ignore[arg-type]
|
|
195
251
|
default_collection="tools/list",
|
|
196
252
|
)
|
|
197
253
|
|
|
198
254
|
self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter(
|
|
199
255
|
key_value=self._stats,
|
|
200
|
-
pydantic_model=list[Resource],
|
|
256
|
+
pydantic_model=list[Resource], # type: ignore[arg-type]
|
|
201
257
|
default_collection="resources/list",
|
|
202
258
|
)
|
|
203
259
|
|
|
204
260
|
self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter(
|
|
205
261
|
key_value=self._stats,
|
|
206
|
-
pydantic_model=list[Prompt],
|
|
262
|
+
pydantic_model=list[Prompt], # type: ignore[arg-type]
|
|
207
263
|
default_collection="prompts/list",
|
|
208
264
|
)
|
|
209
265
|
|
|
210
|
-
self._read_resource_cache: PydanticAdapter[
|
|
211
|
-
list[CachableReadResourceContents]
|
|
212
|
-
] = PydanticAdapter(
|
|
213
|
-
key_value=self._stats,
|
|
214
|
-
pydantic_model=list[CachableReadResourceContents],
|
|
215
|
-
default_collection="resources/read",
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
self._get_prompt_cache: PydanticAdapter[mcp.types.GetPromptResult] = (
|
|
266
|
+
self._read_resource_cache: PydanticAdapter[CachableResourceResult] = (
|
|
219
267
|
PydanticAdapter(
|
|
220
268
|
key_value=self._stats,
|
|
221
|
-
pydantic_model=
|
|
222
|
-
default_collection="
|
|
269
|
+
pydantic_model=CachableResourceResult, # type: ignore[arg-type]
|
|
270
|
+
default_collection="resources/read",
|
|
223
271
|
)
|
|
224
272
|
)
|
|
225
273
|
|
|
274
|
+
self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter(
|
|
275
|
+
key_value=self._stats,
|
|
276
|
+
pydantic_model=CachablePromptResult, # type: ignore[arg-type]
|
|
277
|
+
default_collection="prompts/get",
|
|
278
|
+
)
|
|
279
|
+
|
|
226
280
|
self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter(
|
|
227
281
|
key_value=self._stats,
|
|
228
|
-
pydantic_model=CachableToolResult,
|
|
282
|
+
pydantic_model=CachableToolResult, # type: ignore[arg-type]
|
|
229
283
|
default_collection="tools/call",
|
|
230
284
|
)
|
|
231
285
|
|
|
@@ -256,7 +310,6 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
256
310
|
annotations=tool.annotations,
|
|
257
311
|
meta=tool.meta,
|
|
258
312
|
tags=tool.tags,
|
|
259
|
-
enabled=tool.enabled,
|
|
260
313
|
)
|
|
261
314
|
for tool in tools
|
|
262
315
|
]
|
|
@@ -295,7 +348,6 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
295
348
|
meta=resource.meta,
|
|
296
349
|
mime_type=resource.mime_type,
|
|
297
350
|
annotations=resource.annotations,
|
|
298
|
-
enabled=resource.enabled,
|
|
299
351
|
uri=resource.uri,
|
|
300
352
|
)
|
|
301
353
|
for resource in resources
|
|
@@ -333,7 +385,6 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
333
385
|
description=prompt.description,
|
|
334
386
|
tags=prompt.tags,
|
|
335
387
|
meta=prompt.meta,
|
|
336
|
-
enabled=prompt.enabled,
|
|
337
388
|
arguments=prompt.arguments,
|
|
338
389
|
)
|
|
339
390
|
for prompt in prompts
|
|
@@ -384,23 +435,21 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
384
435
|
async def on_read_resource(
|
|
385
436
|
self,
|
|
386
437
|
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
|
387
|
-
call_next: CallNext[
|
|
388
|
-
|
|
389
|
-
],
|
|
390
|
-
) -> Sequence[ReadResourceContents]:
|
|
438
|
+
call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult],
|
|
439
|
+
) -> ResourceResult:
|
|
391
440
|
"""Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
392
441
|
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
393
442
|
if self._read_resource_settings.get("enabled") is False:
|
|
394
443
|
return await call_next(context=context)
|
|
395
444
|
|
|
396
445
|
cache_key: str = str(context.message.uri)
|
|
397
|
-
cached_value:
|
|
446
|
+
cached_value: CachableResourceResult | None
|
|
398
447
|
|
|
399
448
|
if cached_value := await self._read_resource_cache.get(key=cache_key):
|
|
400
|
-
return
|
|
449
|
+
return cached_value.unwrap()
|
|
401
450
|
|
|
402
|
-
value:
|
|
403
|
-
cached_value =
|
|
451
|
+
value: ResourceResult = await call_next(context=context)
|
|
452
|
+
cached_value = CachableResourceResult.wrap(value)
|
|
404
453
|
|
|
405
454
|
await self._read_resource_cache.put(
|
|
406
455
|
key=cache_key,
|
|
@@ -408,16 +457,14 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
408
457
|
ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS),
|
|
409
458
|
)
|
|
410
459
|
|
|
411
|
-
return
|
|
460
|
+
return cached_value.unwrap()
|
|
412
461
|
|
|
413
462
|
@override
|
|
414
463
|
async def on_get_prompt(
|
|
415
464
|
self,
|
|
416
465
|
context: MiddlewareContext[mcp.types.GetPromptRequestParams],
|
|
417
|
-
call_next: CallNext[
|
|
418
|
-
|
|
419
|
-
],
|
|
420
|
-
) -> mcp.types.GetPromptResult:
|
|
466
|
+
call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult],
|
|
467
|
+
) -> PromptResult:
|
|
421
468
|
"""Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
422
469
|
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
423
470
|
if self._get_prompt_settings.get("enabled") is False:
|
|
@@ -426,13 +473,13 @@ class ResponseCachingMiddleware(Middleware):
|
|
|
426
473
|
cache_key: str = f"{context.message.name}:{_get_arguments_str(arguments=context.message.arguments)}"
|
|
427
474
|
|
|
428
475
|
if cached_value := await self._get_prompt_cache.get(key=cache_key):
|
|
429
|
-
return cached_value
|
|
476
|
+
return cached_value.unwrap()
|
|
430
477
|
|
|
431
|
-
value:
|
|
478
|
+
value: PromptResult = await call_next(context=context)
|
|
432
479
|
|
|
433
480
|
await self._get_prompt_cache.put(
|
|
434
481
|
key=cache_key,
|
|
435
|
-
value=value,
|
|
482
|
+
value=CachablePromptResult.wrap(value),
|
|
436
483
|
ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS),
|
|
437
484
|
)
|
|
438
485
|
|
|
@@ -15,11 +15,10 @@ from typing import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
import mcp.types as mt
|
|
18
|
-
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
19
18
|
from typing_extensions import TypeVar
|
|
20
19
|
|
|
21
|
-
from fastmcp.prompts.prompt import Prompt
|
|
22
|
-
from fastmcp.resources.resource import Resource
|
|
20
|
+
from fastmcp.prompts.prompt import Prompt, PromptResult
|
|
21
|
+
from fastmcp.resources.resource import Resource, ResourceResult
|
|
23
22
|
from fastmcp.resources.template import ResourceTemplate
|
|
24
23
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
25
24
|
|
|
@@ -164,17 +163,15 @@ class Middleware:
|
|
|
164
163
|
async def on_read_resource(
|
|
165
164
|
self,
|
|
166
165
|
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
167
|
-
call_next: CallNext[
|
|
168
|
-
|
|
169
|
-
],
|
|
170
|
-
) -> Sequence[ReadResourceContents]:
|
|
166
|
+
call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult],
|
|
167
|
+
) -> ResourceResult:
|
|
171
168
|
return await call_next(context)
|
|
172
169
|
|
|
173
170
|
async def on_get_prompt(
|
|
174
171
|
self,
|
|
175
172
|
context: MiddlewareContext[mt.GetPromptRequestParams],
|
|
176
|
-
call_next: CallNext[mt.GetPromptRequestParams,
|
|
177
|
-
) ->
|
|
173
|
+
call_next: CallNext[mt.GetPromptRequestParams, PromptResult],
|
|
174
|
+
) -> PromptResult:
|
|
178
175
|
return await call_next(context)
|
|
179
176
|
|
|
180
177
|
async def on_list_tools(
|