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.
Files changed (68) hide show
  1. fastmcp/cli/cli.py +6 -6
  2. fastmcp/cli/install/claude_code.py +3 -3
  3. fastmcp/cli/install/claude_desktop.py +3 -3
  4. fastmcp/cli/install/cursor.py +7 -7
  5. fastmcp/cli/install/gemini_cli.py +3 -3
  6. fastmcp/cli/install/mcp_json.py +3 -3
  7. fastmcp/cli/run.py +13 -8
  8. fastmcp/client/auth/oauth.py +100 -208
  9. fastmcp/client/client.py +11 -11
  10. fastmcp/client/logging.py +18 -14
  11. fastmcp/client/oauth_callback.py +81 -171
  12. fastmcp/client/transports.py +76 -22
  13. fastmcp/contrib/component_manager/component_service.py +6 -6
  14. fastmcp/contrib/mcp_mixin/README.md +32 -1
  15. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  16. fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
  17. fastmcp/experimental/utilities/openapi/parser.py +23 -3
  18. fastmcp/prompts/prompt.py +13 -6
  19. fastmcp/prompts/prompt_manager.py +16 -101
  20. fastmcp/resources/resource.py +13 -6
  21. fastmcp/resources/resource_manager.py +5 -164
  22. fastmcp/resources/template.py +107 -17
  23. fastmcp/server/auth/auth.py +40 -32
  24. fastmcp/server/auth/jwt_issuer.py +289 -0
  25. fastmcp/server/auth/oauth_proxy.py +1238 -234
  26. fastmcp/server/auth/oidc_proxy.py +8 -6
  27. fastmcp/server/auth/providers/auth0.py +12 -6
  28. fastmcp/server/auth/providers/aws.py +13 -2
  29. fastmcp/server/auth/providers/azure.py +137 -124
  30. fastmcp/server/auth/providers/descope.py +4 -6
  31. fastmcp/server/auth/providers/github.py +13 -7
  32. fastmcp/server/auth/providers/google.py +13 -7
  33. fastmcp/server/auth/providers/introspection.py +281 -0
  34. fastmcp/server/auth/providers/jwt.py +8 -2
  35. fastmcp/server/auth/providers/scalekit.py +179 -0
  36. fastmcp/server/auth/providers/supabase.py +172 -0
  37. fastmcp/server/auth/providers/workos.py +16 -13
  38. fastmcp/server/context.py +89 -34
  39. fastmcp/server/http.py +53 -16
  40. fastmcp/server/low_level.py +121 -2
  41. fastmcp/server/middleware/caching.py +469 -0
  42. fastmcp/server/middleware/error_handling.py +6 -2
  43. fastmcp/server/middleware/logging.py +48 -37
  44. fastmcp/server/middleware/middleware.py +28 -15
  45. fastmcp/server/middleware/rate_limiting.py +3 -3
  46. fastmcp/server/proxy.py +6 -6
  47. fastmcp/server/server.py +638 -183
  48. fastmcp/settings.py +22 -9
  49. fastmcp/tools/tool.py +7 -3
  50. fastmcp/tools/tool_manager.py +22 -108
  51. fastmcp/tools/tool_transform.py +3 -3
  52. fastmcp/utilities/cli.py +2 -2
  53. fastmcp/utilities/components.py +5 -0
  54. fastmcp/utilities/inspect.py +77 -21
  55. fastmcp/utilities/logging.py +118 -8
  56. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  57. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  58. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  59. fastmcp/utilities/tests.py +87 -4
  60. fastmcp/utilities/types.py +1 -1
  61. fastmcp/utilities/ui.py +497 -0
  62. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/METADATA +8 -4
  63. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/RECORD +66 -62
  64. fastmcp/cli/claude.py +0 -135
  65. fastmcp/utilities/storage.py +0 -204
  66. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/WHEEL +0 -0
  67. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/entry_points.txt +0 -0
  68. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/licenses/LICENSE +0 -0
@@ -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
- return logging.getLogger(f"fastmcp.{name}")
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
- # Only configure the FastMCP logger namespace
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
- logger.setLevel(level)
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: str | None = Field(
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: str | None = Field(
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[str] | None = Field(
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"
@@ -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."""
@@ -293,7 +293,7 @@ class Audio:
293
293
 
294
294
 
295
295
  class File:
296
- """Helper class for returning audio from tools."""
296
+ """Helper class for returning file data from tools."""
297
297
 
298
298
  def __init__(
299
299
  self,