fastmcp 2.12.3__py3-none-any.whl → 2.12.5__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/install/gemini_cli.py +0 -1
- fastmcp/cli/run.py +2 -2
- fastmcp/client/auth/oauth.py +49 -36
- fastmcp/client/client.py +12 -2
- fastmcp/contrib/mcp_mixin/README.md +2 -2
- fastmcp/experimental/utilities/openapi/schemas.py +31 -5
- fastmcp/server/auth/auth.py +3 -3
- fastmcp/server/auth/oauth_proxy.py +42 -12
- fastmcp/server/auth/oidc_proxy.py +348 -0
- fastmcp/server/auth/providers/auth0.py +174 -0
- fastmcp/server/auth/providers/aws.py +237 -0
- fastmcp/server/auth/providers/azure.py +6 -2
- fastmcp/server/auth/providers/descope.py +172 -0
- fastmcp/server/auth/providers/github.py +6 -2
- fastmcp/server/auth/providers/google.py +6 -2
- fastmcp/server/auth/providers/workos.py +6 -2
- fastmcp/server/context.py +7 -6
- fastmcp/server/http.py +1 -1
- fastmcp/server/middleware/logging.py +147 -116
- fastmcp/server/middleware/middleware.py +3 -2
- fastmcp/server/openapi.py +5 -1
- fastmcp/server/server.py +36 -31
- fastmcp/settings.py +27 -5
- fastmcp/tools/tool.py +4 -2
- fastmcp/utilities/json_schema.py +18 -1
- fastmcp/utilities/logging.py +66 -4
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +2 -1
- fastmcp/utilities/storage.py +204 -0
- fastmcp/utilities/tests.py +8 -6
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/METADATA +121 -48
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/RECORD +34 -29
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -16,7 +16,131 @@ def default_serializer(data: Any) -> str:
|
|
|
16
16
|
return pydantic_core.to_json(data, fallback=str).decode()
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class
|
|
19
|
+
class BaseLoggingMiddleware(Middleware):
|
|
20
|
+
"""Base class for logging middleware."""
|
|
21
|
+
|
|
22
|
+
logger: Logger
|
|
23
|
+
log_level: int
|
|
24
|
+
include_payloads: bool
|
|
25
|
+
include_payload_length: bool
|
|
26
|
+
estimate_payload_tokens: bool
|
|
27
|
+
max_payload_length: int | None
|
|
28
|
+
methods: list[str] | None
|
|
29
|
+
structured_logging: bool
|
|
30
|
+
payload_serializer: Callable[[Any], str] | None
|
|
31
|
+
|
|
32
|
+
def _serialize_payload(self, context: MiddlewareContext[Any]) -> str:
|
|
33
|
+
payload: str
|
|
34
|
+
|
|
35
|
+
if not self.payload_serializer:
|
|
36
|
+
payload = default_serializer(context.message)
|
|
37
|
+
else:
|
|
38
|
+
try:
|
|
39
|
+
payload = self.payload_serializer(context.message)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
self.logger.warning(
|
|
42
|
+
f"Failed to serialize payload due to {e}: {context.type} {context.method} {context.source}."
|
|
43
|
+
)
|
|
44
|
+
payload = default_serializer(context.message)
|
|
45
|
+
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
def _format_message(self, message: dict[str, str | int]) -> str:
|
|
49
|
+
"""Format a message for logging."""
|
|
50
|
+
if self.structured_logging:
|
|
51
|
+
return json.dumps(message)
|
|
52
|
+
else:
|
|
53
|
+
return " ".join([f"{k}={v}" for k, v in message.items()])
|
|
54
|
+
|
|
55
|
+
def _get_timestamp_from_context(self, context: MiddlewareContext[Any]) -> str:
|
|
56
|
+
"""Get a timestamp from the context."""
|
|
57
|
+
return context.timestamp.isoformat()
|
|
58
|
+
|
|
59
|
+
def _create_before_message(
|
|
60
|
+
self, context: MiddlewareContext[Any], event: str
|
|
61
|
+
) -> dict[str, str | int]:
|
|
62
|
+
message = self._create_base_message(context, event)
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
self.include_payloads
|
|
66
|
+
or self.include_payload_length
|
|
67
|
+
or self.estimate_payload_tokens
|
|
68
|
+
):
|
|
69
|
+
payload = self._serialize_payload(context)
|
|
70
|
+
|
|
71
|
+
if self.include_payload_length or self.estimate_payload_tokens:
|
|
72
|
+
payload_length = len(payload)
|
|
73
|
+
payload_tokens = payload_length // 4
|
|
74
|
+
if self.estimate_payload_tokens:
|
|
75
|
+
message["payload_tokens"] = payload_tokens
|
|
76
|
+
if self.include_payload_length:
|
|
77
|
+
message["payload_length"] = payload_length
|
|
78
|
+
|
|
79
|
+
if self.max_payload_length and len(payload) > self.max_payload_length:
|
|
80
|
+
payload = payload[: self.max_payload_length] + "..."
|
|
81
|
+
|
|
82
|
+
if self.include_payloads:
|
|
83
|
+
message["payload"] = payload
|
|
84
|
+
message["payload_type"] = type(context.message).__name__
|
|
85
|
+
|
|
86
|
+
return message
|
|
87
|
+
|
|
88
|
+
def _create_after_message(
|
|
89
|
+
self, context: MiddlewareContext[Any], event: str
|
|
90
|
+
) -> dict[str, str | int]:
|
|
91
|
+
return self._create_base_message(context, event)
|
|
92
|
+
|
|
93
|
+
def _create_base_message(
|
|
94
|
+
self,
|
|
95
|
+
context: MiddlewareContext[Any],
|
|
96
|
+
event: str,
|
|
97
|
+
) -> dict[str, str | int]:
|
|
98
|
+
"""Format a message for logging."""
|
|
99
|
+
|
|
100
|
+
parts: dict[str, str | int] = {
|
|
101
|
+
"event": event,
|
|
102
|
+
"timestamp": self._get_timestamp_from_context(context),
|
|
103
|
+
"method": context.method or "unknown",
|
|
104
|
+
"type": context.type,
|
|
105
|
+
"source": context.source,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parts
|
|
109
|
+
|
|
110
|
+
async def on_message(
|
|
111
|
+
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
112
|
+
) -> Any:
|
|
113
|
+
"""Log all messages."""
|
|
114
|
+
|
|
115
|
+
if self.methods and context.method not in self.methods:
|
|
116
|
+
return await call_next(context)
|
|
117
|
+
|
|
118
|
+
request_start_log_message = self._create_before_message(
|
|
119
|
+
context, "request_start"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
formatted_message = self._format_message(request_start_log_message)
|
|
123
|
+
self.logger.log(self.log_level, f"Processing message: {formatted_message}")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
result = await call_next(context)
|
|
127
|
+
|
|
128
|
+
request_success_log_message = self._create_after_message(
|
|
129
|
+
context, "request_success"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
formatted_message = self._format_message(request_success_log_message)
|
|
133
|
+
self.logger.log(self.log_level, f"Completed message: {formatted_message}")
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self.logger.log(
|
|
138
|
+
logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
|
|
139
|
+
)
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class LoggingMiddleware(BaseLoggingMiddleware):
|
|
20
144
|
"""Middleware that provides comprehensive request and response logging.
|
|
21
145
|
|
|
22
146
|
Logs all MCP messages with configurable detail levels. Useful for debugging,
|
|
@@ -37,9 +161,12 @@ class LoggingMiddleware(Middleware):
|
|
|
37
161
|
|
|
38
162
|
def __init__(
|
|
39
163
|
self,
|
|
164
|
+
*,
|
|
40
165
|
logger: logging.Logger | None = None,
|
|
41
166
|
log_level: int = logging.INFO,
|
|
42
167
|
include_payloads: bool = False,
|
|
168
|
+
include_payload_length: bool = False,
|
|
169
|
+
estimate_payload_tokens: bool = False,
|
|
43
170
|
max_payload_length: int = 1000,
|
|
44
171
|
methods: list[str] | None = None,
|
|
45
172
|
payload_serializer: Callable[[Any], str] | None = None,
|
|
@@ -50,68 +177,25 @@ class LoggingMiddleware(Middleware):
|
|
|
50
177
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests'
|
|
51
178
|
log_level: Log level for messages (default: INFO)
|
|
52
179
|
include_payloads: Whether to include message payloads in logs
|
|
180
|
+
include_payload_length: Whether to include response size in logs
|
|
181
|
+
estimate_payload_tokens: Whether to estimate response tokens
|
|
53
182
|
max_payload_length: Maximum length of payload to log (prevents huge logs)
|
|
54
183
|
methods: List of methods to log. If None, logs all methods.
|
|
184
|
+
payload_serializer: Callable that converts objects to a JSON string for the
|
|
185
|
+
payload. If not provided, uses FastMCP's default tool serializer.
|
|
55
186
|
"""
|
|
56
187
|
self.logger: Logger = logger or logging.getLogger("fastmcp.requests")
|
|
57
|
-
self.log_level
|
|
188
|
+
self.log_level = log_level
|
|
58
189
|
self.include_payloads: bool = include_payloads
|
|
190
|
+
self.include_payload_length: bool = include_payload_length
|
|
191
|
+
self.estimate_payload_tokens: bool = estimate_payload_tokens
|
|
59
192
|
self.max_payload_length: int = max_payload_length
|
|
60
193
|
self.methods: list[str] | None = methods
|
|
61
194
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
195
|
+
self.structured_logging: bool = False
|
|
62
196
|
|
|
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
|
-
|
|
74
|
-
if not self.payload_serializer:
|
|
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
197
|
|
|
88
|
-
|
|
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):
|
|
198
|
+
class StructuredLoggingMiddleware(BaseLoggingMiddleware):
|
|
115
199
|
"""Middleware that provides structured JSON logging for better log analysis.
|
|
116
200
|
|
|
117
201
|
Outputs structured logs that are easier to parse and analyze with log
|
|
@@ -129,9 +213,12 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
129
213
|
|
|
130
214
|
def __init__(
|
|
131
215
|
self,
|
|
216
|
+
*,
|
|
132
217
|
logger: logging.Logger | None = None,
|
|
133
218
|
log_level: int = logging.INFO,
|
|
134
219
|
include_payloads: bool = False,
|
|
220
|
+
include_payload_length: bool = False,
|
|
221
|
+
estimate_payload_tokens: bool = False,
|
|
135
222
|
methods: list[str] | None = None,
|
|
136
223
|
payload_serializer: Callable[[Any], str] | None = None,
|
|
137
224
|
):
|
|
@@ -141,74 +228,18 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
141
228
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
|
|
142
229
|
log_level: Log level for messages (default: INFO)
|
|
143
230
|
include_payloads: Whether to include message payloads in logs
|
|
231
|
+
include_payload_length: Whether to include payload size in logs
|
|
232
|
+
estimate_payload_tokens: Whether to estimate token count using length // 4
|
|
144
233
|
methods: List of methods to log. If None, logs all methods.
|
|
145
|
-
|
|
234
|
+
payload_serializer: Callable that converts objects to a JSON string for the
|
|
146
235
|
payload. If not provided, uses FastMCP's default tool serializer.
|
|
147
236
|
"""
|
|
148
237
|
self.logger: Logger = logger or logging.getLogger("fastmcp.structured")
|
|
149
238
|
self.log_level: int = log_level
|
|
150
239
|
self.include_payloads: bool = include_payloads
|
|
240
|
+
self.include_payload_length: bool = include_payload_length
|
|
241
|
+
self.estimate_payload_tokens: bool = estimate_payload_tokens
|
|
151
242
|
self.methods: list[str] | None = methods
|
|
152
243
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
167
|
-
if self.include_payloads:
|
|
168
|
-
payload: str
|
|
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
|
|
244
|
+
self.max_payload_length: int | None = None
|
|
245
|
+
self.structured_logging: bool = True
|
|
@@ -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
|
|
@@ -154,8 +155,8 @@ class Middleware:
|
|
|
154
155
|
async def on_read_resource(
|
|
155
156
|
self,
|
|
156
157
|
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
157
|
-
call_next: CallNext[mt.ReadResourceRequestParams,
|
|
158
|
-
) ->
|
|
158
|
+
call_next: CallNext[mt.ReadResourceRequestParams, list[ReadResourceContents]],
|
|
159
|
+
) -> list[ReadResourceContents]:
|
|
159
160
|
return await call_next(context)
|
|
160
161
|
|
|
161
162
|
async def on_get_prompt(
|
fastmcp/server/openapi.py
CHANGED
|
@@ -785,6 +785,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
785
785
|
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
|
786
786
|
|
|
787
787
|
# Process routes
|
|
788
|
+
num_excluded = 0
|
|
788
789
|
route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
|
|
789
790
|
for route in http_routes:
|
|
790
791
|
# Determine route type based on mappings or default rules
|
|
@@ -823,8 +824,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
823
824
|
self._create_openapi_template(route, component_name, tags=route_tags)
|
|
824
825
|
elif route_type == MCPType.EXCLUDE:
|
|
825
826
|
logger.info(f"Excluding route: {route.method} {route.path}")
|
|
827
|
+
num_excluded += 1
|
|
826
828
|
|
|
827
|
-
logger.info(
|
|
829
|
+
logger.info(
|
|
830
|
+
f"Created FastMCP OpenAPI server with {len(http_routes) - num_excluded} routes"
|
|
831
|
+
)
|
|
828
832
|
|
|
829
833
|
def _generate_default_name(
|
|
830
834
|
self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
|
fastmcp/server/server.py
CHANGED
|
@@ -8,11 +8,7 @@ import re
|
|
|
8
8
|
import secrets
|
|
9
9
|
import warnings
|
|
10
10
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
11
|
-
from contextlib import
|
|
12
|
-
AbstractAsyncContextManager,
|
|
13
|
-
AsyncExitStack,
|
|
14
|
-
asynccontextmanager,
|
|
15
|
-
)
|
|
11
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
|
16
12
|
from dataclasses import dataclass
|
|
17
13
|
from functools import partial
|
|
18
14
|
from pathlib import Path
|
|
@@ -65,7 +61,7 @@ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
|
|
65
61
|
from fastmcp.tools.tool_transform import ToolTransformConfig
|
|
66
62
|
from fastmcp.utilities.cli import log_server_banner
|
|
67
63
|
from fastmcp.utilities.components import FastMCPComponent
|
|
68
|
-
from fastmcp.utilities.logging import get_logger
|
|
64
|
+
from fastmcp.utilities.logging import get_logger, temporary_log_level
|
|
69
65
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
70
66
|
|
|
71
67
|
if TYPE_CHECKING:
|
|
@@ -208,8 +204,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
208
204
|
# if auth is `NotSet`, try to create a provider from the environment
|
|
209
205
|
if auth is NotSet:
|
|
210
206
|
if fastmcp.settings.server_auth is not None:
|
|
211
|
-
#
|
|
212
|
-
auth = fastmcp.settings.
|
|
207
|
+
# server_auth_class returns the class itself
|
|
208
|
+
auth = fastmcp.settings.server_auth_class()
|
|
213
209
|
else:
|
|
214
210
|
auth = None
|
|
215
211
|
self.auth = cast(AuthProvider | None, auth)
|
|
@@ -329,6 +325,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
329
325
|
def instructions(self) -> str | None:
|
|
330
326
|
return self._mcp_server.instructions
|
|
331
327
|
|
|
328
|
+
@instructions.setter
|
|
329
|
+
def instructions(self, value: str | None) -> None:
|
|
330
|
+
self._mcp_server.instructions = value
|
|
331
|
+
|
|
332
332
|
@property
|
|
333
333
|
def version(self) -> str | None:
|
|
334
334
|
return self._mcp_server.version
|
|
@@ -1481,9 +1481,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1481
1481
|
meta=meta,
|
|
1482
1482
|
)
|
|
1483
1483
|
|
|
1484
|
-
async def run_stdio_async(
|
|
1485
|
-
|
|
1484
|
+
async def run_stdio_async(
|
|
1485
|
+
self, show_banner: bool = True, log_level: str | None = None
|
|
1486
|
+
) -> None:
|
|
1487
|
+
"""Run the server using stdio transport.
|
|
1486
1488
|
|
|
1489
|
+
Args:
|
|
1490
|
+
show_banner: Whether to display the server banner
|
|
1491
|
+
log_level: Log level for the server
|
|
1492
|
+
"""
|
|
1487
1493
|
# Display server banner
|
|
1488
1494
|
if show_banner:
|
|
1489
1495
|
log_server_banner(
|
|
@@ -1491,15 +1497,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1491
1497
|
transport="stdio",
|
|
1492
1498
|
)
|
|
1493
1499
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1500
|
+
with temporary_log_level(log_level):
|
|
1501
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
1502
|
+
logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'")
|
|
1503
|
+
await self._mcp_server.run(
|
|
1504
|
+
read_stream,
|
|
1505
|
+
write_stream,
|
|
1506
|
+
self._mcp_server.create_initialization_options(
|
|
1507
|
+
NotificationOptions(tools_changed=True)
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1503
1510
|
|
|
1504
1511
|
async def run_http_async(
|
|
1505
1512
|
self,
|
|
@@ -1525,7 +1532,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1525
1532
|
middleware: A list of middleware to apply to the app
|
|
1526
1533
|
stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http)
|
|
1527
1534
|
"""
|
|
1528
|
-
|
|
1529
1535
|
host = host or self._deprecated_settings.host
|
|
1530
1536
|
port = port or self._deprecated_settings.port
|
|
1531
1537
|
default_log_level_to_use = (
|
|
@@ -1566,14 +1572,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1566
1572
|
if "log_config" not in config_kwargs and "log_level" not in config_kwargs:
|
|
1567
1573
|
config_kwargs["log_level"] = default_log_level_to_use
|
|
1568
1574
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
+
with temporary_log_level(log_level):
|
|
1576
|
+
config = uvicorn.Config(app, host=host, port=port, **config_kwargs)
|
|
1577
|
+
server = uvicorn.Server(config)
|
|
1578
|
+
path = app.state.path.lstrip("/") # type: ignore
|
|
1579
|
+
logger.info(
|
|
1580
|
+
f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
|
|
1581
|
+
)
|
|
1575
1582
|
|
|
1576
|
-
|
|
1583
|
+
await server.serve()
|
|
1577
1584
|
|
|
1578
1585
|
async def run_sse_async(
|
|
1579
1586
|
self,
|
|
@@ -2122,10 +2129,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
2122
2129
|
# - Connected clients: reuse existing session for all requests
|
|
2123
2130
|
# - Disconnected clients: create fresh sessions per request for isolation
|
|
2124
2131
|
if client.is_connected():
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
logger = get_logger(__name__)
|
|
2128
|
-
logger.info(
|
|
2132
|
+
_proxy_logger = get_logger(__name__)
|
|
2133
|
+
_proxy_logger.info(
|
|
2129
2134
|
"Proxy detected connected client - reusing existing session for all requests. "
|
|
2130
2135
|
"This may cause context mixing in concurrent scenarios."
|
|
2131
2136
|
)
|
fastmcp/settings.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations as _annotations
|
|
|
3
3
|
import inspect
|
|
4
4
|
import warnings
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Annotated, Any, Literal
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
7
7
|
|
|
8
8
|
from pydantic import Field, ImportString, field_validator
|
|
9
9
|
from pydantic.fields import FieldInfo
|
|
@@ -23,6 +23,9 @@ LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
|
23
23
|
|
|
24
24
|
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
|
|
25
25
|
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from fastmcp.server.auth.auth import AuthProvider
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
class ExtendedEnvSettingsSource(EnvSettingsSource):
|
|
28
31
|
"""
|
|
@@ -258,7 +261,7 @@ class Settings(BaseSettings):
|
|
|
258
261
|
|
|
259
262
|
# Auth settings
|
|
260
263
|
server_auth: Annotated[
|
|
261
|
-
|
|
264
|
+
str | None,
|
|
262
265
|
Field(
|
|
263
266
|
description=inspect.cleandoc(
|
|
264
267
|
"""
|
|
@@ -266,9 +269,9 @@ class Settings(BaseSettings):
|
|
|
266
269
|
the full module path to an AuthProvider class (e.g.,
|
|
267
270
|
'fastmcp.server.auth.providers.google.GoogleProvider').
|
|
268
271
|
|
|
269
|
-
The specified class will be imported and instantiated automatically
|
|
270
|
-
Any class that inherits from AuthProvider
|
|
271
|
-
custom implementations.
|
|
272
|
+
The specified class will be imported and instantiated automatically
|
|
273
|
+
during FastMCP server creation. Any class that inherits from AuthProvider
|
|
274
|
+
can be used, including custom implementations.
|
|
272
275
|
|
|
273
276
|
If None, no automatic configuration will take place.
|
|
274
277
|
|
|
@@ -357,6 +360,25 @@ class Settings(BaseSettings):
|
|
|
357
360
|
),
|
|
358
361
|
] = True
|
|
359
362
|
|
|
363
|
+
@property
|
|
364
|
+
def server_auth_class(self) -> AuthProvider | None:
|
|
365
|
+
from fastmcp.utilities.types import get_cached_typeadapter
|
|
366
|
+
|
|
367
|
+
if not self.server_auth:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# https://github.com/jlowin/fastmcp/issues/1749
|
|
371
|
+
# Pydantic imports the module in an ImportString during model validation, but we don't want the server
|
|
372
|
+
# auth module imported during settings creation as it imports dependencies we aren't ready for yet.
|
|
373
|
+
# To fix this while limiting breaking changes, we delay the import by only creating the ImportString
|
|
374
|
+
# when the class is actually needed
|
|
375
|
+
|
|
376
|
+
type_adapter = get_cached_typeadapter(ImportString)
|
|
377
|
+
|
|
378
|
+
auth_class = type_adapter.validate_python(self.server_auth)
|
|
379
|
+
|
|
380
|
+
return auth_class
|
|
381
|
+
|
|
360
382
|
|
|
361
383
|
def __getattr__(name: str):
|
|
362
384
|
"""
|
fastmcp/tools/tool.py
CHANGED
|
@@ -413,7 +413,9 @@ class ParsedFunction:
|
|
|
413
413
|
|
|
414
414
|
input_type_adapter = get_cached_typeadapter(fn)
|
|
415
415
|
input_schema = input_type_adapter.json_schema()
|
|
416
|
-
input_schema = compress_schema(
|
|
416
|
+
input_schema = compress_schema(
|
|
417
|
+
input_schema, prune_params=prune_params, prune_titles=True
|
|
418
|
+
)
|
|
417
419
|
|
|
418
420
|
output_schema = None
|
|
419
421
|
# Get the return annotation from the signature
|
|
@@ -473,7 +475,7 @@ class ParsedFunction:
|
|
|
473
475
|
else:
|
|
474
476
|
output_schema = base_schema
|
|
475
477
|
|
|
476
|
-
output_schema = compress_schema(output_schema)
|
|
478
|
+
output_schema = compress_schema(output_schema, prune_titles=True)
|
|
477
479
|
|
|
478
480
|
except PydanticSchemaGenerationError as e:
|
|
479
481
|
if "_UnserializableType" not in str(e):
|
fastmcp/utilities/json_schema.py
CHANGED
|
@@ -109,8 +109,25 @@ def _single_pass_optimize(
|
|
|
109
109
|
root_refs.add(referenced_def)
|
|
110
110
|
|
|
111
111
|
# Apply cleanups
|
|
112
|
+
# Only remove "title" if it's a schema metadata field
|
|
113
|
+
# Schema objects have keywords like "type", "properties", "$ref", etc.
|
|
114
|
+
# If we see these, then "title" is metadata, not a property name
|
|
112
115
|
if prune_titles and "title" in node:
|
|
113
|
-
node
|
|
116
|
+
# Check if this looks like a schema node
|
|
117
|
+
if any(
|
|
118
|
+
k in node
|
|
119
|
+
for k in [
|
|
120
|
+
"type",
|
|
121
|
+
"properties",
|
|
122
|
+
"$ref",
|
|
123
|
+
"items",
|
|
124
|
+
"allOf",
|
|
125
|
+
"oneOf",
|
|
126
|
+
"anyOf",
|
|
127
|
+
"required",
|
|
128
|
+
]
|
|
129
|
+
):
|
|
130
|
+
node.pop("title")
|
|
114
131
|
|
|
115
132
|
if (
|
|
116
133
|
prune_additional_properties
|