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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
import time
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
from logging import Logger
|
|
7
8
|
from typing import Any
|
|
@@ -16,7 +17,135 @@ def default_serializer(data: Any) -> str:
|
|
|
16
17
|
return pydantic_core.to_json(data, fallback=str).decode()
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
class
|
|
20
|
+
class BaseLoggingMiddleware(Middleware):
|
|
21
|
+
"""Base class for logging middleware."""
|
|
22
|
+
|
|
23
|
+
logger: Logger
|
|
24
|
+
log_level: int
|
|
25
|
+
include_payloads: bool
|
|
26
|
+
include_payload_length: bool
|
|
27
|
+
estimate_payload_tokens: bool
|
|
28
|
+
max_payload_length: int | None
|
|
29
|
+
methods: list[str] | None
|
|
30
|
+
structured_logging: bool
|
|
31
|
+
payload_serializer: Callable[[Any], str] | None
|
|
32
|
+
|
|
33
|
+
def _serialize_payload(self, context: MiddlewareContext[Any]) -> str:
|
|
34
|
+
payload: str
|
|
35
|
+
|
|
36
|
+
if not self.payload_serializer:
|
|
37
|
+
payload = default_serializer(context.message)
|
|
38
|
+
else:
|
|
39
|
+
try:
|
|
40
|
+
payload = self.payload_serializer(context.message)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
self.logger.warning(
|
|
43
|
+
f"Failed to serialize payload due to {e}: {context.type} {context.method} {context.source}."
|
|
44
|
+
)
|
|
45
|
+
payload = default_serializer(context.message)
|
|
46
|
+
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
def _format_message(self, message: dict[str, str | int | float]) -> str:
|
|
50
|
+
"""Format a message for logging."""
|
|
51
|
+
if self.structured_logging:
|
|
52
|
+
return json.dumps(message)
|
|
53
|
+
else:
|
|
54
|
+
return " ".join([f"{k}={v}" for k, v in message.items()])
|
|
55
|
+
|
|
56
|
+
def _create_before_message(
|
|
57
|
+
self, context: MiddlewareContext[Any]
|
|
58
|
+
) -> dict[str, str | int | float]:
|
|
59
|
+
message = {
|
|
60
|
+
"event": context.type + "_start",
|
|
61
|
+
"method": context.method or "unknown",
|
|
62
|
+
"source": context.source,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
self.include_payloads
|
|
67
|
+
or self.include_payload_length
|
|
68
|
+
or self.estimate_payload_tokens
|
|
69
|
+
):
|
|
70
|
+
payload = self._serialize_payload(context)
|
|
71
|
+
|
|
72
|
+
if self.include_payload_length or self.estimate_payload_tokens:
|
|
73
|
+
payload_length = len(payload)
|
|
74
|
+
payload_tokens = payload_length // 4
|
|
75
|
+
if self.estimate_payload_tokens:
|
|
76
|
+
message["payload_tokens"] = payload_tokens
|
|
77
|
+
if self.include_payload_length:
|
|
78
|
+
message["payload_length"] = payload_length
|
|
79
|
+
|
|
80
|
+
if self.max_payload_length and len(payload) > self.max_payload_length:
|
|
81
|
+
payload = payload[: self.max_payload_length] + "..."
|
|
82
|
+
|
|
83
|
+
if self.include_payloads:
|
|
84
|
+
message["payload"] = payload
|
|
85
|
+
message["payload_type"] = type(context.message).__name__
|
|
86
|
+
|
|
87
|
+
return message
|
|
88
|
+
|
|
89
|
+
def _create_error_message(
|
|
90
|
+
self,
|
|
91
|
+
context: MiddlewareContext[Any],
|
|
92
|
+
start_time: float,
|
|
93
|
+
error: Exception,
|
|
94
|
+
) -> dict[str, str | int | float]:
|
|
95
|
+
duration_ms: float = _get_duration_ms(start_time)
|
|
96
|
+
message = {
|
|
97
|
+
"event": context.type + "_error",
|
|
98
|
+
"method": context.method or "unknown",
|
|
99
|
+
"source": context.source,
|
|
100
|
+
"duration_ms": duration_ms,
|
|
101
|
+
"error": str(object=error),
|
|
102
|
+
}
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
def _create_after_message(
|
|
106
|
+
self,
|
|
107
|
+
context: MiddlewareContext[Any],
|
|
108
|
+
start_time: float,
|
|
109
|
+
) -> dict[str, str | int | float]:
|
|
110
|
+
duration_ms: float = _get_duration_ms(start_time)
|
|
111
|
+
message = {
|
|
112
|
+
"event": context.type + "_success",
|
|
113
|
+
"method": context.method or "unknown",
|
|
114
|
+
"source": context.source,
|
|
115
|
+
"duration_ms": duration_ms,
|
|
116
|
+
}
|
|
117
|
+
return message
|
|
118
|
+
|
|
119
|
+
def _log_message(
|
|
120
|
+
self, message: dict[str, str | int | float], log_level: int | None = None
|
|
121
|
+
):
|
|
122
|
+
self.logger.log(log_level or self.log_level, self._format_message(message))
|
|
123
|
+
|
|
124
|
+
async def on_message(
|
|
125
|
+
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Log messages for configured methods."""
|
|
128
|
+
|
|
129
|
+
if self.methods and context.method not in self.methods:
|
|
130
|
+
return await call_next(context)
|
|
131
|
+
|
|
132
|
+
self._log_message(self._create_before_message(context))
|
|
133
|
+
|
|
134
|
+
start_time = time.perf_counter()
|
|
135
|
+
try:
|
|
136
|
+
result = await call_next(context)
|
|
137
|
+
|
|
138
|
+
self._log_message(self._create_after_message(context, start_time))
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
except Exception as e:
|
|
142
|
+
self._log_message(
|
|
143
|
+
self._create_error_message(context, start_time, e), logging.ERROR
|
|
144
|
+
)
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class LoggingMiddleware(BaseLoggingMiddleware):
|
|
20
149
|
"""Middleware that provides comprehensive request and response logging.
|
|
21
150
|
|
|
22
151
|
Logs all MCP messages with configurable detail levels. Useful for debugging,
|
|
@@ -37,9 +166,12 @@ class LoggingMiddleware(Middleware):
|
|
|
37
166
|
|
|
38
167
|
def __init__(
|
|
39
168
|
self,
|
|
169
|
+
*,
|
|
40
170
|
logger: logging.Logger | None = None,
|
|
41
171
|
log_level: int = logging.INFO,
|
|
42
172
|
include_payloads: bool = False,
|
|
173
|
+
include_payload_length: bool = False,
|
|
174
|
+
estimate_payload_tokens: bool = False,
|
|
43
175
|
max_payload_length: int = 1000,
|
|
44
176
|
methods: list[str] | None = None,
|
|
45
177
|
payload_serializer: Callable[[Any], str] | None = None,
|
|
@@ -50,68 +182,25 @@ class LoggingMiddleware(Middleware):
|
|
|
50
182
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests'
|
|
51
183
|
log_level: Log level for messages (default: INFO)
|
|
52
184
|
include_payloads: Whether to include message payloads in logs
|
|
185
|
+
include_payload_length: Whether to include response size in logs
|
|
186
|
+
estimate_payload_tokens: Whether to estimate response tokens
|
|
53
187
|
max_payload_length: Maximum length of payload to log (prevents huge logs)
|
|
54
188
|
methods: List of methods to log. If None, logs all methods.
|
|
189
|
+
payload_serializer: Callable that converts objects to a JSON string for the
|
|
190
|
+
payload. If not provided, uses FastMCP's default tool serializer.
|
|
55
191
|
"""
|
|
56
|
-
self.logger: Logger = logger or logging.getLogger("fastmcp.
|
|
57
|
-
self.log_level
|
|
192
|
+
self.logger: Logger = logger or logging.getLogger("fastmcp.middleware.logging")
|
|
193
|
+
self.log_level = log_level
|
|
58
194
|
self.include_payloads: bool = include_payloads
|
|
195
|
+
self.include_payload_length: bool = include_payload_length
|
|
196
|
+
self.estimate_payload_tokens: bool = estimate_payload_tokens
|
|
59
197
|
self.max_payload_length: int = max_payload_length
|
|
60
198
|
self.methods: list[str] | None = methods
|
|
61
199
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
200
|
+
self.structured_logging: bool = False
|
|
62
201
|
|
|
63
|
-
def _format_message(self, context: MiddlewareContext[Any]) -> str:
|
|
64
|
-
"""Format a message for logging."""
|
|
65
|
-
parts = [
|
|
66
|
-
f"source={context.source}",
|
|
67
|
-
f"type={context.type}",
|
|
68
|
-
f"method={context.method or 'unknown'}",
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
if self.include_payloads:
|
|
72
|
-
payload: str
|
|
73
202
|
|
|
74
|
-
|
|
75
|
-
payload = default_serializer(context.message)
|
|
76
|
-
else:
|
|
77
|
-
try:
|
|
78
|
-
payload = self.payload_serializer(context.message)
|
|
79
|
-
except Exception as e:
|
|
80
|
-
self.logger.warning(
|
|
81
|
-
f"Failed {e} to serialize payload: {context.type} {context.method} {context.source}."
|
|
82
|
-
)
|
|
83
|
-
payload = default_serializer(context.message)
|
|
84
|
-
|
|
85
|
-
if len(payload) > self.max_payload_length:
|
|
86
|
-
payload = payload[: self.max_payload_length] + "..."
|
|
87
|
-
|
|
88
|
-
parts.append(f"payload={payload}")
|
|
89
|
-
return " ".join(parts)
|
|
90
|
-
|
|
91
|
-
async def on_message(
|
|
92
|
-
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
93
|
-
) -> Any:
|
|
94
|
-
"""Log all messages."""
|
|
95
|
-
message_info = self._format_message(context)
|
|
96
|
-
if self.methods and context.method not in self.methods:
|
|
97
|
-
return await call_next(context)
|
|
98
|
-
|
|
99
|
-
self.logger.log(self.log_level, f"Processing message: {message_info}")
|
|
100
|
-
|
|
101
|
-
try:
|
|
102
|
-
result = await call_next(context)
|
|
103
|
-
self.logger.log(
|
|
104
|
-
self.log_level, f"Completed message: {context.method or 'unknown'}"
|
|
105
|
-
)
|
|
106
|
-
return result
|
|
107
|
-
except Exception as e:
|
|
108
|
-
self.logger.log(
|
|
109
|
-
logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
|
|
110
|
-
)
|
|
111
|
-
raise
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
class StructuredLoggingMiddleware(Middleware):
|
|
203
|
+
class StructuredLoggingMiddleware(BaseLoggingMiddleware):
|
|
115
204
|
"""Middleware that provides structured JSON logging for better log analysis.
|
|
116
205
|
|
|
117
206
|
Outputs structured logs that are easier to parse and analyze with log
|
|
@@ -129,9 +218,12 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
129
218
|
|
|
130
219
|
def __init__(
|
|
131
220
|
self,
|
|
221
|
+
*,
|
|
132
222
|
logger: logging.Logger | None = None,
|
|
133
223
|
log_level: int = logging.INFO,
|
|
134
224
|
include_payloads: bool = False,
|
|
225
|
+
include_payload_length: bool = False,
|
|
226
|
+
estimate_payload_tokens: bool = False,
|
|
135
227
|
methods: list[str] | None = None,
|
|
136
228
|
payload_serializer: Callable[[Any], str] | None = None,
|
|
137
229
|
):
|
|
@@ -141,74 +233,24 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
141
233
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
|
|
142
234
|
log_level: Log level for messages (default: INFO)
|
|
143
235
|
include_payloads: Whether to include message payloads in logs
|
|
236
|
+
include_payload_length: Whether to include payload size in logs
|
|
237
|
+
estimate_payload_tokens: Whether to estimate token count using length // 4
|
|
144
238
|
methods: List of methods to log. If None, logs all methods.
|
|
145
|
-
|
|
239
|
+
payload_serializer: Callable that converts objects to a JSON string for the
|
|
146
240
|
payload. If not provided, uses FastMCP's default tool serializer.
|
|
147
241
|
"""
|
|
148
|
-
self.logger: Logger = logger or logging.getLogger(
|
|
242
|
+
self.logger: Logger = logger or logging.getLogger(
|
|
243
|
+
"fastmcp.middleware.structured_logging"
|
|
244
|
+
)
|
|
149
245
|
self.log_level: int = log_level
|
|
150
246
|
self.include_payloads: bool = include_payloads
|
|
247
|
+
self.include_payload_length: bool = include_payload_length
|
|
248
|
+
self.estimate_payload_tokens: bool = estimate_payload_tokens
|
|
151
249
|
self.methods: list[str] | None = methods
|
|
152
250
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
251
|
+
self.max_payload_length: int | None = None
|
|
252
|
+
self.structured_logging: bool = True
|
|
153
253
|
|
|
154
|
-
def _create_log_entry(
|
|
155
|
-
self, context: MiddlewareContext[Any], event: str, **extra_fields: Any
|
|
156
|
-
) -> dict[str, Any]:
|
|
157
|
-
"""Create a structured log entry."""
|
|
158
|
-
entry = {
|
|
159
|
-
"event": event,
|
|
160
|
-
"timestamp": context.timestamp.isoformat(),
|
|
161
|
-
"source": context.source,
|
|
162
|
-
"type": context.type,
|
|
163
|
-
"method": context.method,
|
|
164
|
-
**extra_fields,
|
|
165
|
-
}
|
|
166
254
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if not self.payload_serializer:
|
|
171
|
-
payload = default_serializer(context.message)
|
|
172
|
-
else:
|
|
173
|
-
try:
|
|
174
|
-
payload = self.payload_serializer(context.message)
|
|
175
|
-
except Exception as e:
|
|
176
|
-
self.logger.warning(
|
|
177
|
-
f"Failed {str(e)} to serialize payload: {context.type} {context.method} {context.source}."
|
|
178
|
-
)
|
|
179
|
-
payload = default_serializer(context.message)
|
|
180
|
-
|
|
181
|
-
entry["payload"] = payload
|
|
182
|
-
|
|
183
|
-
return entry
|
|
184
|
-
|
|
185
|
-
async def on_message(
|
|
186
|
-
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
187
|
-
) -> Any:
|
|
188
|
-
"""Log structured message information."""
|
|
189
|
-
start_entry = self._create_log_entry(context, "request_start")
|
|
190
|
-
if self.methods and context.method not in self.methods:
|
|
191
|
-
return await call_next(context)
|
|
192
|
-
|
|
193
|
-
self.logger.log(self.log_level, json.dumps(start_entry))
|
|
194
|
-
|
|
195
|
-
try:
|
|
196
|
-
result = await call_next(context)
|
|
197
|
-
|
|
198
|
-
success_entry = self._create_log_entry(
|
|
199
|
-
context,
|
|
200
|
-
"request_success",
|
|
201
|
-
result_type=type(result).__name__ if result else None,
|
|
202
|
-
)
|
|
203
|
-
self.logger.log(self.log_level, json.dumps(success_entry))
|
|
204
|
-
|
|
205
|
-
return result
|
|
206
|
-
except Exception as e:
|
|
207
|
-
error_entry = self._create_log_entry(
|
|
208
|
-
context,
|
|
209
|
-
"request_error",
|
|
210
|
-
error_type=type(e).__name__,
|
|
211
|
-
error_message=str(e),
|
|
212
|
-
)
|
|
213
|
-
self.logger.log(logging.ERROR, json.dumps(error_entry))
|
|
214
|
-
raise
|
|
255
|
+
def _get_duration_ms(start_time: float, /) -> float:
|
|
256
|
+
return round(number=(time.perf_counter() - start_time) * 1000, ndigits=2)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from collections.abc import Awaitable
|
|
4
|
+
from collections.abc import Awaitable, Sequence
|
|
5
5
|
from dataclasses import dataclass, field, replace
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
from functools import partial
|
|
@@ -15,6 +15,7 @@ from typing import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
import mcp.types as mt
|
|
18
|
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
18
19
|
from typing_extensions import TypeVar
|
|
19
20
|
|
|
20
21
|
from fastmcp.prompts.prompt import Prompt
|
|
@@ -26,9 +27,9 @@ if TYPE_CHECKING:
|
|
|
26
27
|
from fastmcp.server.context import Context
|
|
27
28
|
|
|
28
29
|
__all__ = [
|
|
30
|
+
"CallNext",
|
|
29
31
|
"Middleware",
|
|
30
32
|
"MiddlewareContext",
|
|
31
|
-
"CallNext",
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
logger = logging.getLogger(__name__)
|
|
@@ -98,6 +99,8 @@ class Middleware:
|
|
|
98
99
|
handler = call_next
|
|
99
100
|
|
|
100
101
|
match context.method:
|
|
102
|
+
case "initialize":
|
|
103
|
+
handler = partial(self.on_initialize, call_next=handler)
|
|
101
104
|
case "tools/call":
|
|
102
105
|
handler = partial(self.on_call_tool, call_next=handler)
|
|
103
106
|
case "resources/read":
|
|
@@ -132,18 +135,25 @@ class Middleware:
|
|
|
132
135
|
|
|
133
136
|
async def on_request(
|
|
134
137
|
self,
|
|
135
|
-
context: MiddlewareContext[mt.Request],
|
|
136
|
-
call_next: CallNext[mt.Request, Any],
|
|
138
|
+
context: MiddlewareContext[mt.Request[Any, Any]],
|
|
139
|
+
call_next: CallNext[mt.Request[Any, Any], Any],
|
|
137
140
|
) -> Any:
|
|
138
141
|
return await call_next(context)
|
|
139
142
|
|
|
140
143
|
async def on_notification(
|
|
141
144
|
self,
|
|
142
|
-
context: MiddlewareContext[mt.Notification],
|
|
143
|
-
call_next: CallNext[mt.Notification, Any],
|
|
145
|
+
context: MiddlewareContext[mt.Notification[Any, Any]],
|
|
146
|
+
call_next: CallNext[mt.Notification[Any, Any], Any],
|
|
144
147
|
) -> Any:
|
|
145
148
|
return await call_next(context)
|
|
146
149
|
|
|
150
|
+
async def on_initialize(
|
|
151
|
+
self,
|
|
152
|
+
context: MiddlewareContext[mt.InitializeRequest],
|
|
153
|
+
call_next: CallNext[mt.InitializeRequest, None],
|
|
154
|
+
) -> None:
|
|
155
|
+
return await call_next(context)
|
|
156
|
+
|
|
147
157
|
async def on_call_tool(
|
|
148
158
|
self,
|
|
149
159
|
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
@@ -154,8 +164,10 @@ class Middleware:
|
|
|
154
164
|
async def on_read_resource(
|
|
155
165
|
self,
|
|
156
166
|
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
157
|
-
call_next: CallNext[
|
|
158
|
-
|
|
167
|
+
call_next: CallNext[
|
|
168
|
+
mt.ReadResourceRequestParams, Sequence[ReadResourceContents]
|
|
169
|
+
],
|
|
170
|
+
) -> Sequence[ReadResourceContents]:
|
|
159
171
|
return await call_next(context)
|
|
160
172
|
|
|
161
173
|
async def on_get_prompt(
|
|
@@ -168,27 +180,29 @@ class Middleware:
|
|
|
168
180
|
async def on_list_tools(
|
|
169
181
|
self,
|
|
170
182
|
context: MiddlewareContext[mt.ListToolsRequest],
|
|
171
|
-
call_next: CallNext[mt.ListToolsRequest,
|
|
172
|
-
) ->
|
|
183
|
+
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
|
|
184
|
+
) -> Sequence[Tool]:
|
|
173
185
|
return await call_next(context)
|
|
174
186
|
|
|
175
187
|
async def on_list_resources(
|
|
176
188
|
self,
|
|
177
189
|
context: MiddlewareContext[mt.ListResourcesRequest],
|
|
178
|
-
call_next: CallNext[mt.ListResourcesRequest,
|
|
179
|
-
) ->
|
|
190
|
+
call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]],
|
|
191
|
+
) -> Sequence[Resource]:
|
|
180
192
|
return await call_next(context)
|
|
181
193
|
|
|
182
194
|
async def on_list_resource_templates(
|
|
183
195
|
self,
|
|
184
196
|
context: MiddlewareContext[mt.ListResourceTemplatesRequest],
|
|
185
|
-
call_next: CallNext[
|
|
186
|
-
|
|
197
|
+
call_next: CallNext[
|
|
198
|
+
mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]
|
|
199
|
+
],
|
|
200
|
+
) -> Sequence[ResourceTemplate]:
|
|
187
201
|
return await call_next(context)
|
|
188
202
|
|
|
189
203
|
async def on_list_prompts(
|
|
190
204
|
self,
|
|
191
205
|
context: MiddlewareContext[mt.ListPromptsRequest],
|
|
192
|
-
call_next: CallNext[mt.ListPromptsRequest,
|
|
193
|
-
) ->
|
|
206
|
+
call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]],
|
|
207
|
+
) -> Sequence[Prompt]:
|
|
194
208
|
return await call_next(context)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Rate limiting middleware for protecting FastMCP servers from abuse."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
import time
|
|
5
4
|
from collections import defaultdict, deque
|
|
6
5
|
from collections.abc import Callable
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
8
|
+
import anyio
|
|
9
9
|
from mcp import McpError
|
|
10
10
|
from mcp.types import ErrorData
|
|
11
11
|
|
|
@@ -33,7 +33,7 @@ class TokenBucketRateLimiter:
|
|
|
33
33
|
self.refill_rate = refill_rate
|
|
34
34
|
self.tokens = capacity
|
|
35
35
|
self.last_refill = time.time()
|
|
36
|
-
self._lock =
|
|
36
|
+
self._lock = anyio.Lock()
|
|
37
37
|
|
|
38
38
|
async def consume(self, tokens: int = 1) -> bool:
|
|
39
39
|
"""Try to consume tokens from the bucket.
|
|
@@ -71,7 +71,7 @@ class SlidingWindowRateLimiter:
|
|
|
71
71
|
self.max_requests = max_requests
|
|
72
72
|
self.window_seconds = window_seconds
|
|
73
73
|
self.requests = deque()
|
|
74
|
-
self._lock =
|
|
74
|
+
self._lock = anyio.Lock()
|
|
75
75
|
|
|
76
76
|
async def is_allowed(self) -> bool:
|
|
77
77
|
"""Check if a request is allowed."""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""A middleware for injecting tools into the MCP server context."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from logging import Logger
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
import mcp.types
|
|
8
|
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
9
|
+
from mcp.types import Prompt
|
|
10
|
+
from pydantic import AnyUrl
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from fastmcp.server.context import Context
|
|
14
|
+
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
|
|
15
|
+
from fastmcp.tools.tool import Tool, ToolResult
|
|
16
|
+
from fastmcp.utilities.logging import get_logger
|
|
17
|
+
|
|
18
|
+
logger: Logger = get_logger(name=__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolInjectionMiddleware(Middleware):
|
|
22
|
+
"""A middleware for injecting tools into the context."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, tools: Sequence[Tool]):
|
|
25
|
+
"""Initialize the tool injection middleware."""
|
|
26
|
+
self._tools_to_inject: Sequence[Tool] = tools
|
|
27
|
+
self._tools_to_inject_by_name: dict[str, Tool] = {
|
|
28
|
+
tool.name: tool for tool in tools
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
async def on_list_tools(
|
|
33
|
+
self,
|
|
34
|
+
context: MiddlewareContext[mcp.types.ListToolsRequest],
|
|
35
|
+
call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]],
|
|
36
|
+
) -> Sequence[Tool]:
|
|
37
|
+
"""Inject tools into the response."""
|
|
38
|
+
return [*self._tools_to_inject, *await call_next(context)]
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
async def on_call_tool(
|
|
42
|
+
self,
|
|
43
|
+
context: MiddlewareContext[mcp.types.CallToolRequestParams],
|
|
44
|
+
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
|
|
45
|
+
) -> ToolResult:
|
|
46
|
+
"""Intercept tool calls to injected tools."""
|
|
47
|
+
if context.message.name in self._tools_to_inject_by_name:
|
|
48
|
+
tool = self._tools_to_inject_by_name[context.message.name]
|
|
49
|
+
return await tool.run(arguments=context.message.arguments or {})
|
|
50
|
+
|
|
51
|
+
return await call_next(context)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def list_prompts(context: Context) -> list[Prompt]:
|
|
55
|
+
"""List prompts available on the server."""
|
|
56
|
+
return await context.list_prompts()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
list_prompts_tool = Tool.from_function(
|
|
60
|
+
fn=list_prompts,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def get_prompt(
|
|
65
|
+
context: Context,
|
|
66
|
+
name: Annotated[str, "The name of the prompt to render."],
|
|
67
|
+
arguments: Annotated[
|
|
68
|
+
dict[str, Any] | None, "The arguments to pass to the prompt."
|
|
69
|
+
] = None,
|
|
70
|
+
) -> mcp.types.GetPromptResult:
|
|
71
|
+
"""Render a prompt available on the server."""
|
|
72
|
+
return await context.get_prompt(name=name, arguments=arguments)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
get_prompt_tool = Tool.from_function(
|
|
76
|
+
fn=get_prompt,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PromptToolMiddleware(ToolInjectionMiddleware):
|
|
81
|
+
"""A middleware for injecting prompts as tools into the context."""
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
tools: list[Tool] = [list_prompts_tool, get_prompt_tool]
|
|
85
|
+
super().__init__(tools=tools)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def list_resources(context: Context) -> list[mcp.types.Resource]:
|
|
89
|
+
"""List resources available on the server."""
|
|
90
|
+
return await context.list_resources()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
list_resources_tool = Tool.from_function(
|
|
94
|
+
fn=list_resources,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def read_resource(
|
|
99
|
+
context: Context,
|
|
100
|
+
uri: Annotated[AnyUrl | str, "The URI of the resource to read."],
|
|
101
|
+
) -> list[ReadResourceContents]:
|
|
102
|
+
"""Read a resource available on the server."""
|
|
103
|
+
return await context.read_resource(uri=uri)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
read_resource_tool = Tool.from_function(
|
|
107
|
+
fn=read_resource,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ResourceToolMiddleware(ToolInjectionMiddleware):
|
|
112
|
+
"""A middleware for injecting resources as tools into the context."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
tools: list[Tool] = [list_resources_tool, read_resource_tool]
|
|
116
|
+
super().__init__(tools=tools)
|
fastmcp/server/openapi.py
CHANGED
|
@@ -513,11 +513,11 @@ class OpenAPITool(Tool):
|
|
|
513
513
|
if e.response.text:
|
|
514
514
|
error_message += f" - {e.response.text}"
|
|
515
515
|
|
|
516
|
-
raise ValueError(error_message)
|
|
516
|
+
raise ValueError(error_message) from e
|
|
517
517
|
|
|
518
518
|
except httpx.RequestError as e:
|
|
519
519
|
# Handle request errors (connection, timeout, etc.)
|
|
520
|
-
raise ValueError(f"Request error: {
|
|
520
|
+
raise ValueError(f"Request error: {e!s}") from e
|
|
521
521
|
|
|
522
522
|
|
|
523
523
|
class OpenAPIResource(Resource):
|
|
@@ -531,9 +531,11 @@ class OpenAPIResource(Resource):
|
|
|
531
531
|
name: str,
|
|
532
532
|
description: str,
|
|
533
533
|
mime_type: str = "application/json",
|
|
534
|
-
tags: set[str] =
|
|
534
|
+
tags: set[str] | None = None,
|
|
535
535
|
timeout: float | None = None,
|
|
536
536
|
):
|
|
537
|
+
if tags is None:
|
|
538
|
+
tags = set()
|
|
537
539
|
super().__init__(
|
|
538
540
|
uri=AnyUrl(uri), # Convert string to AnyUrl
|
|
539
541
|
name=name,
|
|
@@ -632,11 +634,11 @@ class OpenAPIResource(Resource):
|
|
|
632
634
|
if e.response.text:
|
|
633
635
|
error_message += f" - {e.response.text}"
|
|
634
636
|
|
|
635
|
-
raise ValueError(error_message)
|
|
637
|
+
raise ValueError(error_message) from e
|
|
636
638
|
|
|
637
639
|
except httpx.RequestError as e:
|
|
638
640
|
# Handle request errors (connection, timeout, etc.)
|
|
639
|
-
raise ValueError(f"Request error: {
|
|
641
|
+
raise ValueError(f"Request error: {e!s}") from e
|
|
640
642
|
|
|
641
643
|
|
|
642
644
|
class OpenAPIResourceTemplate(ResourceTemplate):
|
|
@@ -650,9 +652,11 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|
|
650
652
|
name: str,
|
|
651
653
|
description: str,
|
|
652
654
|
parameters: dict[str, Any],
|
|
653
|
-
tags: set[str] =
|
|
655
|
+
tags: set[str] | None = None,
|
|
654
656
|
timeout: float | None = None,
|
|
655
657
|
):
|
|
658
|
+
if tags is None:
|
|
659
|
+
tags = set()
|
|
656
660
|
super().__init__(
|
|
657
661
|
uri_template=uri_template,
|
|
658
662
|
name=name,
|
|
@@ -785,6 +789,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
785
789
|
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
|
786
790
|
|
|
787
791
|
# Process routes
|
|
792
|
+
num_excluded = 0
|
|
788
793
|
route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
|
|
789
794
|
for route in http_routes:
|
|
790
795
|
# Determine route type based on mappings or default rules
|
|
@@ -823,8 +828,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
823
828
|
self._create_openapi_template(route, component_name, tags=route_tags)
|
|
824
829
|
elif route_type == MCPType.EXCLUDE:
|
|
825
830
|
logger.info(f"Excluding route: {route.method} {route.path}")
|
|
831
|
+
num_excluded += 1
|
|
826
832
|
|
|
827
|
-
logger.info(
|
|
833
|
+
logger.info(
|
|
834
|
+
f"Created FastMCP OpenAPI server with {len(http_routes) - num_excluded} routes"
|
|
835
|
+
)
|
|
828
836
|
|
|
829
837
|
def _generate_default_name(
|
|
830
838
|
self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
|