fastmcp 2.12.4__py3-none-any.whl → 2.13.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/cli/cli.py +6 -6
- fastmcp/cli/install/claude_code.py +3 -3
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +81 -171
- fastmcp/client/transports.py +76 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/jwt_issuer.py +289 -0
- fastmcp/server/auth/oauth_proxy.py +1238 -234
- fastmcp/server/auth/oidc_proxy.py +8 -6
- fastmcp/server/auth/providers/auth0.py +12 -6
- fastmcp/server/auth/providers/aws.py +13 -2
- fastmcp/server/auth/providers/azure.py +137 -124
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +13 -7
- fastmcp/server/auth/providers/google.py +13 -7
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +16 -13
- fastmcp/server/context.py +89 -34
- fastmcp/server/http.py +53 -16
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +638 -183
- fastmcp/settings.py +22 -9
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +22 -108
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +2 -2
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +497 -0
- {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/METADATA +8 -4
- {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/RECORD +66 -62
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/licenses/LICENSE +0 -0
fastmcp/utilities/logging.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any, Literal, cast
|
|
|
6
6
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from rich.logging import RichHandler
|
|
9
|
+
from typing_extensions import override
|
|
9
10
|
|
|
10
11
|
import fastmcp
|
|
11
12
|
|
|
@@ -19,7 +20,10 @@ def get_logger(name: str) -> logging.Logger:
|
|
|
19
20
|
Returns:
|
|
20
21
|
a configured logger instance
|
|
21
22
|
"""
|
|
22
|
-
|
|
23
|
+
if name.startswith("fastmcp."):
|
|
24
|
+
return logging.getLogger(name=name)
|
|
25
|
+
|
|
26
|
+
return logging.getLogger(name=f"fastmcp.{name}")
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
def configure_logging(
|
|
@@ -47,25 +51,48 @@ def configure_logging(
|
|
|
47
51
|
if logger is None:
|
|
48
52
|
logger = logging.getLogger("fastmcp")
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
formatter = logging.Formatter("%(message)s")
|
|
55
|
+
|
|
56
|
+
# Don't propagate to the root logger
|
|
57
|
+
logger.propagate = False
|
|
58
|
+
logger.setLevel(level)
|
|
59
|
+
|
|
60
|
+
# Configure the handler for normal logs
|
|
51
61
|
handler = RichHandler(
|
|
52
62
|
console=Console(stderr=True),
|
|
53
|
-
rich_tracebacks=enable_rich_tracebacks,
|
|
54
63
|
**rich_kwargs,
|
|
55
64
|
)
|
|
56
|
-
formatter = logging.Formatter("%(message)s")
|
|
57
65
|
handler.setFormatter(formatter)
|
|
58
66
|
|
|
59
|
-
|
|
67
|
+
# filter to exclude tracebacks
|
|
68
|
+
handler.addFilter(lambda record: record.exc_info is None)
|
|
69
|
+
|
|
70
|
+
# Configure the handler for tracebacks, for tracebacks we use a compressed format:
|
|
71
|
+
# no path or level name to maximize width available for the traceback
|
|
72
|
+
# suppress framework frames and limit the number of frames to 3
|
|
73
|
+
|
|
74
|
+
import mcp
|
|
75
|
+
import pydantic
|
|
76
|
+
|
|
77
|
+
traceback_handler = RichHandler(
|
|
78
|
+
console=Console(stderr=True),
|
|
79
|
+
show_path=False,
|
|
80
|
+
show_level=False,
|
|
81
|
+
rich_tracebacks=enable_rich_tracebacks,
|
|
82
|
+
tracebacks_max_frames=3,
|
|
83
|
+
tracebacks_suppress=[fastmcp, mcp, pydantic],
|
|
84
|
+
**rich_kwargs,
|
|
85
|
+
)
|
|
86
|
+
traceback_handler.setFormatter(formatter)
|
|
87
|
+
|
|
88
|
+
traceback_handler.addFilter(lambda record: record.exc_info is not None)
|
|
60
89
|
|
|
61
90
|
# Remove any existing handlers to avoid duplicates on reconfiguration
|
|
62
91
|
for hdlr in logger.handlers[:]:
|
|
63
92
|
logger.removeHandler(hdlr)
|
|
64
93
|
|
|
65
94
|
logger.addHandler(handler)
|
|
66
|
-
|
|
67
|
-
# Don't propagate to the root logger
|
|
68
|
-
logger.propagate = False
|
|
95
|
+
logger.addHandler(traceback_handler)
|
|
69
96
|
|
|
70
97
|
|
|
71
98
|
@contextlib.contextmanager
|
|
@@ -118,3 +145,86 @@ def temporary_log_level(
|
|
|
118
145
|
)
|
|
119
146
|
else:
|
|
120
147
|
yield
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _ClampedLogFilter(logging.Filter):
|
|
151
|
+
min_level: tuple[int, str] | None
|
|
152
|
+
max_level: tuple[int, str] | None
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
157
|
+
| None = None,
|
|
158
|
+
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
159
|
+
| None = None,
|
|
160
|
+
):
|
|
161
|
+
self.min_level = None
|
|
162
|
+
self.max_level = None
|
|
163
|
+
|
|
164
|
+
if min_level_no := self._level_to_no(level=min_level):
|
|
165
|
+
self.min_level = (min_level_no, str(min_level))
|
|
166
|
+
if max_level_no := self._level_to_no(level=max_level):
|
|
167
|
+
self.max_level = (max_level_no, str(max_level))
|
|
168
|
+
|
|
169
|
+
super().__init__()
|
|
170
|
+
|
|
171
|
+
def _level_to_no(
|
|
172
|
+
self, level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None
|
|
173
|
+
) -> int | None:
|
|
174
|
+
if level == "DEBUG":
|
|
175
|
+
return logging.DEBUG
|
|
176
|
+
elif level == "INFO":
|
|
177
|
+
return logging.INFO
|
|
178
|
+
elif level == "WARNING":
|
|
179
|
+
return logging.WARNING
|
|
180
|
+
elif level == "ERROR":
|
|
181
|
+
return logging.ERROR
|
|
182
|
+
elif level == "CRITICAL":
|
|
183
|
+
return logging.CRITICAL
|
|
184
|
+
else:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
@override
|
|
188
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
189
|
+
if self.max_level:
|
|
190
|
+
max_level_no, max_level_name = self.max_level
|
|
191
|
+
|
|
192
|
+
if record.levelno > max_level_no:
|
|
193
|
+
record.levelno = max_level_no
|
|
194
|
+
record.levelname = max_level_name
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
if self.min_level:
|
|
198
|
+
min_level_no, min_level_name = self.min_level
|
|
199
|
+
if record.levelno < min_level_no:
|
|
200
|
+
record.levelno = min_level_no
|
|
201
|
+
record.levelname = min_level_name
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _clamp_logger(
|
|
208
|
+
logger: logging.Logger,
|
|
209
|
+
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
|
210
|
+
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Clamp the logger to a minimum and maximum level.
|
|
213
|
+
|
|
214
|
+
If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`.
|
|
215
|
+
If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
min_level: The lower bound of the clamp
|
|
219
|
+
max_level: The upper bound of the clamp
|
|
220
|
+
"""
|
|
221
|
+
_unclamp_logger(logger=logger)
|
|
222
|
+
|
|
223
|
+
logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _unclamp_logger(logger: logging.Logger) -> None:
|
|
227
|
+
"""Remove all clamped log filters from the logger."""
|
|
228
|
+
for filter in logger.filters[:]:
|
|
229
|
+
if isinstance(filter, _ClampedLogFilter):
|
|
230
|
+
logger.removeFilter(filter)
|
|
@@ -28,19 +28,19 @@ class UVEnvironment(Environment):
|
|
|
28
28
|
examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
requirements:
|
|
31
|
+
requirements: Path | None = Field(
|
|
32
32
|
default=None,
|
|
33
33
|
description="Path to requirements.txt file",
|
|
34
34
|
examples=["requirements.txt", "../requirements/prod.txt"],
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
-
project:
|
|
37
|
+
project: Path | None = Field(
|
|
38
38
|
default=None,
|
|
39
39
|
description="Path to project directory containing pyproject.toml",
|
|
40
40
|
examples=[".", "../my-project"],
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
editable: list[
|
|
43
|
+
editable: list[Path] | None = Field(
|
|
44
44
|
default=None,
|
|
45
45
|
description="Directories to install in editable mode",
|
|
46
46
|
examples=[[".", "../my-package"], ["/path/to/package"]],
|
|
@@ -64,7 +64,7 @@ class UVEnvironment(Environment):
|
|
|
64
64
|
|
|
65
65
|
# Add project if specified
|
|
66
66
|
if self.project:
|
|
67
|
-
args.extend(["--project", str(self.project)])
|
|
67
|
+
args.extend(["--project", str(self.project.resolve())])
|
|
68
68
|
|
|
69
69
|
# Add Python version if specified (only if no project, as project has its own Python)
|
|
70
70
|
if self.python and not self.project:
|
|
@@ -78,12 +78,12 @@ class UVEnvironment(Environment):
|
|
|
78
78
|
|
|
79
79
|
# Add requirements file
|
|
80
80
|
if self.requirements:
|
|
81
|
-
args.extend(["--with-requirements", str(self.requirements)])
|
|
81
|
+
args.extend(["--with-requirements", str(self.requirements.resolve())])
|
|
82
82
|
|
|
83
83
|
# Add editable packages
|
|
84
84
|
if self.editable:
|
|
85
85
|
for editable_path in self.editable:
|
|
86
|
-
args.extend(["--with-editable", str(editable_path)])
|
|
86
|
+
args.extend(["--with-editable", str(editable_path.resolve())])
|
|
87
87
|
|
|
88
88
|
# Add the command
|
|
89
89
|
args.extend(command)
|
|
@@ -291,9 +291,9 @@ class MCPServerConfig(BaseModel):
|
|
|
291
291
|
environment = UVEnvironment(
|
|
292
292
|
python=python,
|
|
293
293
|
dependencies=dependencies,
|
|
294
|
-
requirements=requirements,
|
|
295
|
-
project=project,
|
|
296
|
-
editable=[editable] if editable else None,
|
|
294
|
+
requirements=Path(requirements) if requirements else None,
|
|
295
|
+
project=Path(project) if project else None,
|
|
296
|
+
editable=[Path(editable)] if editable else None,
|
|
297
297
|
)
|
|
298
298
|
|
|
299
299
|
# Build deployment config if any deployment args provided
|
|
@@ -250,6 +250,7 @@
|
|
|
250
250
|
"requirements": {
|
|
251
251
|
"anyOf": [
|
|
252
252
|
{
|
|
253
|
+
"format": "path",
|
|
253
254
|
"type": "string"
|
|
254
255
|
},
|
|
255
256
|
{
|
|
@@ -267,6 +268,7 @@
|
|
|
267
268
|
"project": {
|
|
268
269
|
"anyOf": [
|
|
269
270
|
{
|
|
271
|
+
"format": "path",
|
|
270
272
|
"type": "string"
|
|
271
273
|
},
|
|
272
274
|
{
|
|
@@ -285,6 +287,7 @@
|
|
|
285
287
|
"anyOf": [
|
|
286
288
|
{
|
|
287
289
|
"items": {
|
|
290
|
+
"format": "path",
|
|
288
291
|
"type": "string"
|
|
289
292
|
},
|
|
290
293
|
"type": "array"
|
fastmcp/utilities/tests.py
CHANGED
|
@@ -5,8 +5,8 @@ import logging
|
|
|
5
5
|
import multiprocessing
|
|
6
6
|
import socket
|
|
7
7
|
import time
|
|
8
|
-
from collections.abc import Callable, Generator
|
|
9
|
-
from contextlib import contextmanager
|
|
8
|
+
from collections.abc import AsyncGenerator, Callable, Generator
|
|
9
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
10
10
|
from typing import TYPE_CHECKING, Any, Literal
|
|
11
11
|
from urllib.parse import parse_qs, urlparse
|
|
12
12
|
|
|
@@ -66,6 +66,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
|
|
|
66
66
|
host="127.0.0.1",
|
|
67
67
|
port=port,
|
|
68
68
|
log_level="error",
|
|
69
|
+
ws="websockets-sansio",
|
|
69
70
|
)
|
|
70
71
|
)
|
|
71
72
|
uvicorn_server.run()
|
|
@@ -74,11 +75,11 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
|
|
|
74
75
|
@contextmanager
|
|
75
76
|
def run_server_in_process(
|
|
76
77
|
server_fn: Callable[..., None],
|
|
77
|
-
*args,
|
|
78
|
+
*args: Any,
|
|
78
79
|
provide_host_and_port: bool = True,
|
|
79
80
|
host: str = "127.0.0.1",
|
|
80
81
|
port: int | None = None,
|
|
81
|
-
**kwargs,
|
|
82
|
+
**kwargs: Any,
|
|
82
83
|
) -> Generator[str, None, None]:
|
|
83
84
|
"""
|
|
84
85
|
Context manager that runs a FastMCP server in a separate process and
|
|
@@ -139,6 +140,88 @@ def run_server_in_process(
|
|
|
139
140
|
raise RuntimeError("Server process failed to terminate even after kill")
|
|
140
141
|
|
|
141
142
|
|
|
143
|
+
@asynccontextmanager
|
|
144
|
+
async def run_server_async(
|
|
145
|
+
server: FastMCP,
|
|
146
|
+
port: int | None = None,
|
|
147
|
+
transport: Literal["http", "streamable-http", "sse"] = "http",
|
|
148
|
+
path: str = "/mcp",
|
|
149
|
+
host: str = "127.0.0.1",
|
|
150
|
+
) -> AsyncGenerator[str, None]:
|
|
151
|
+
"""
|
|
152
|
+
Start a FastMCP server as an asyncio task for in-process async testing.
|
|
153
|
+
|
|
154
|
+
This is the recommended way to test FastMCP servers. It runs the server
|
|
155
|
+
as an async task in the same process, eliminating subprocess coordination,
|
|
156
|
+
sleeps, and cleanup issues.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
server: FastMCP server instance
|
|
160
|
+
port: Port to bind to (default: find available port)
|
|
161
|
+
transport: Transport type ("http", "streamable-http", or "sse")
|
|
162
|
+
path: URL path for the server (default: "/mcp")
|
|
163
|
+
host: Host to bind to (default: "127.0.0.1")
|
|
164
|
+
|
|
165
|
+
Yields:
|
|
166
|
+
Server URL string
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
```python
|
|
170
|
+
import pytest
|
|
171
|
+
from fastmcp import FastMCP, Client
|
|
172
|
+
from fastmcp.client.transports import StreamableHttpTransport
|
|
173
|
+
from fastmcp.utilities.tests import run_server_async
|
|
174
|
+
|
|
175
|
+
@pytest.fixture
|
|
176
|
+
async def server():
|
|
177
|
+
mcp = FastMCP("test")
|
|
178
|
+
|
|
179
|
+
@mcp.tool()
|
|
180
|
+
def greet(name: str) -> str:
|
|
181
|
+
return f"Hello, {name}!"
|
|
182
|
+
|
|
183
|
+
async with run_server_async(mcp) as url:
|
|
184
|
+
yield url
|
|
185
|
+
|
|
186
|
+
async def test_greet(server: str):
|
|
187
|
+
async with Client(StreamableHttpTransport(server)) as client:
|
|
188
|
+
result = await client.call_tool("greet", {"name": "World"})
|
|
189
|
+
assert result.content[0].text == "Hello, World!"
|
|
190
|
+
```
|
|
191
|
+
"""
|
|
192
|
+
import asyncio
|
|
193
|
+
|
|
194
|
+
if port is None:
|
|
195
|
+
port = find_available_port()
|
|
196
|
+
|
|
197
|
+
# Wait a tiny bit for the port to be released if it was just used
|
|
198
|
+
await asyncio.sleep(0.01)
|
|
199
|
+
|
|
200
|
+
# Start server as a background task
|
|
201
|
+
server_task = asyncio.create_task(
|
|
202
|
+
server.run_http_async(
|
|
203
|
+
host=host,
|
|
204
|
+
port=port,
|
|
205
|
+
transport=transport,
|
|
206
|
+
path=path,
|
|
207
|
+
show_banner=False,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Give the server a moment to start
|
|
212
|
+
await asyncio.sleep(0.1)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
yield f"http://{host}:{port}{path}"
|
|
216
|
+
finally:
|
|
217
|
+
# Cleanup: cancel the task
|
|
218
|
+
server_task.cancel()
|
|
219
|
+
try:
|
|
220
|
+
await server_task
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
142
225
|
@contextmanager
|
|
143
226
|
def caplog_for_fastmcp(caplog):
|
|
144
227
|
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""
|