fastmcp 2.12.5__py3-none-any.whl → 2.13.0__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 (72) hide show
  1. fastmcp/cli/cli.py +7 -6
  2. fastmcp/cli/install/claude_code.py +6 -6
  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 +85 -171
  12. fastmcp/client/transports.py +77 -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/resources/types.py +30 -24
  24. fastmcp/server/auth/auth.py +40 -32
  25. fastmcp/server/auth/handlers/authorize.py +324 -0
  26. fastmcp/server/auth/jwt_issuer.py +236 -0
  27. fastmcp/server/auth/middleware.py +96 -0
  28. fastmcp/server/auth/oauth_proxy.py +1256 -242
  29. fastmcp/server/auth/oidc_proxy.py +23 -6
  30. fastmcp/server/auth/providers/auth0.py +40 -21
  31. fastmcp/server/auth/providers/aws.py +29 -3
  32. fastmcp/server/auth/providers/azure.py +178 -127
  33. fastmcp/server/auth/providers/descope.py +4 -6
  34. fastmcp/server/auth/providers/github.py +29 -8
  35. fastmcp/server/auth/providers/google.py +30 -9
  36. fastmcp/server/auth/providers/introspection.py +281 -0
  37. fastmcp/server/auth/providers/jwt.py +8 -2
  38. fastmcp/server/auth/providers/scalekit.py +179 -0
  39. fastmcp/server/auth/providers/supabase.py +172 -0
  40. fastmcp/server/auth/providers/workos.py +32 -14
  41. fastmcp/server/context.py +122 -36
  42. fastmcp/server/http.py +58 -18
  43. fastmcp/server/low_level.py +121 -2
  44. fastmcp/server/middleware/caching.py +469 -0
  45. fastmcp/server/middleware/error_handling.py +6 -2
  46. fastmcp/server/middleware/logging.py +48 -37
  47. fastmcp/server/middleware/middleware.py +28 -15
  48. fastmcp/server/middleware/rate_limiting.py +3 -3
  49. fastmcp/server/middleware/tool_injection.py +116 -0
  50. fastmcp/server/proxy.py +6 -6
  51. fastmcp/server/server.py +683 -207
  52. fastmcp/settings.py +24 -10
  53. fastmcp/tools/tool.py +7 -3
  54. fastmcp/tools/tool_manager.py +30 -112
  55. fastmcp/tools/tool_transform.py +3 -3
  56. fastmcp/utilities/cli.py +62 -22
  57. fastmcp/utilities/components.py +5 -0
  58. fastmcp/utilities/inspect.py +77 -21
  59. fastmcp/utilities/logging.py +118 -8
  60. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  61. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  62. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  63. fastmcp/utilities/tests.py +87 -4
  64. fastmcp/utilities/types.py +1 -1
  65. fastmcp/utilities/ui.py +617 -0
  66. {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
  67. {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
  68. fastmcp/cli/claude.py +0 -135
  69. fastmcp/utilities/storage.py +0 -204
  70. {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
  71. {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
  72. {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -28,6 +28,7 @@ class ToolInfo:
28
28
  tags: list[str] | None = None
29
29
  enabled: bool | None = None
30
30
  title: str | None = None
31
+ icons: list[dict[str, Any]] | None = None
31
32
  meta: dict[str, Any] | None = None
32
33
 
33
34
 
@@ -42,6 +43,7 @@ class PromptInfo:
42
43
  tags: list[str] | None = None
43
44
  enabled: bool | None = None
44
45
  title: str | None = None
46
+ icons: list[dict[str, Any]] | None = None
45
47
  meta: dict[str, Any] | None = None
46
48
 
47
49
 
@@ -58,6 +60,7 @@ class ResourceInfo:
58
60
  tags: list[str] | None = None
59
61
  enabled: bool | None = None
60
62
  title: str | None = None
63
+ icons: list[dict[str, Any]] | None = None
61
64
  meta: dict[str, Any] | None = None
62
65
 
63
66
 
@@ -75,6 +78,7 @@ class TemplateInfo:
75
78
  tags: list[str] | None = None
76
79
  enabled: bool | None = None
77
80
  title: str | None = None
81
+ icons: list[dict[str, Any]] | None = None
78
82
  meta: dict[str, Any] | None = None
79
83
 
80
84
 
@@ -85,6 +89,8 @@ class FastMCPInfo:
85
89
  name: str
86
90
  instructions: str | None
87
91
  version: str | None # The server's own version string (if specified)
92
+ website_url: str | None
93
+ icons: list[dict[str, Any]] | None
88
94
  fastmcp_version: str # Version of FastMCP generating this manifest
89
95
  mcp_version: str # Version of MCP protocol library
90
96
  server_generation: int # Server generation: 1 (mcp package) or 2 (fastmcp)
@@ -104,21 +110,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
104
110
  Returns:
105
111
  FastMCPInfo dataclass containing the extracted information
106
112
  """
107
- # Get all the components using FastMCP2's direct methods
108
- tools_dict = await mcp.get_tools()
109
- prompts_dict = await mcp.get_prompts()
110
- resources_dict = await mcp.get_resources()
111
- templates_dict = await mcp.get_resource_templates()
113
+ # Get all components via middleware to respect filtering and preserve metadata
114
+ tools_list = await mcp._list_tools_middleware()
115
+ prompts_list = await mcp._list_prompts_middleware()
116
+ resources_list = await mcp._list_resources_middleware()
117
+ templates_list = await mcp._list_resource_templates_middleware()
112
118
 
113
119
  # Extract detailed tool information
114
120
  tool_infos = []
115
- for key, tool in tools_dict.items():
116
- # Convert to MCP tool to get input schema
117
- mcp_tool = tool.to_mcp_tool(name=key)
121
+ for tool in tools_list:
122
+ mcp_tool = tool.to_mcp_tool(name=tool.key)
118
123
  tool_infos.append(
119
124
  ToolInfo(
120
- key=key,
121
- name=tool.name or key,
125
+ key=tool.key,
126
+ name=tool.name or tool.key,
122
127
  description=tool.description,
123
128
  input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
124
129
  output_schema=tool.output_schema,
@@ -126,17 +131,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
126
131
  tags=list(tool.tags) if tool.tags else None,
127
132
  enabled=tool.enabled,
128
133
  title=tool.title,
134
+ icons=[icon.model_dump() for icon in tool.icons]
135
+ if tool.icons
136
+ else None,
129
137
  meta=tool.meta,
130
138
  )
131
139
  )
132
140
 
133
141
  # Extract detailed prompt information
134
142
  prompt_infos = []
135
- for key, prompt in prompts_dict.items():
143
+ for prompt in prompts_list:
136
144
  prompt_infos.append(
137
145
  PromptInfo(
138
- key=key,
139
- name=prompt.name or key,
146
+ key=prompt.key,
147
+ name=prompt.name or prompt.key,
140
148
  description=prompt.description,
141
149
  arguments=[arg.model_dump() for arg in prompt.arguments]
142
150
  if prompt.arguments
@@ -144,17 +152,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
144
152
  tags=list(prompt.tags) if prompt.tags else None,
145
153
  enabled=prompt.enabled,
146
154
  title=prompt.title,
155
+ icons=[icon.model_dump() for icon in prompt.icons]
156
+ if prompt.icons
157
+ else None,
147
158
  meta=prompt.meta,
148
159
  )
149
160
  )
150
161
 
151
162
  # Extract detailed resource information
152
163
  resource_infos = []
153
- for key, resource in resources_dict.items():
164
+ for resource in resources_list:
154
165
  resource_infos.append(
155
166
  ResourceInfo(
156
- key=key,
157
- uri=key, # For v2, key is the URI
167
+ key=resource.key,
168
+ uri=resource.key,
158
169
  name=resource.name,
159
170
  description=resource.description,
160
171
  mime_type=resource.mime_type,
@@ -164,17 +175,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
164
175
  tags=list(resource.tags) if resource.tags else None,
165
176
  enabled=resource.enabled,
166
177
  title=resource.title,
178
+ icons=[icon.model_dump() for icon in resource.icons]
179
+ if resource.icons
180
+ else None,
167
181
  meta=resource.meta,
168
182
  )
169
183
  )
170
184
 
171
185
  # Extract detailed template information
172
186
  template_infos = []
173
- for key, template in templates_dict.items():
187
+ for template in templates_list:
174
188
  template_infos.append(
175
189
  TemplateInfo(
176
- key=key,
177
- uri_template=key, # For v2, key is the URI template
190
+ key=template.key,
191
+ uri_template=template.key,
178
192
  name=template.name,
179
193
  description=template.description,
180
194
  mime_type=template.mime_type,
@@ -185,6 +199,9 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
185
199
  tags=list(template.tags) if template.tags else None,
186
200
  enabled=template.enabled,
187
201
  title=template.title,
202
+ icons=[icon.model_dump() for icon in template.icons]
203
+ if template.icons
204
+ else None,
188
205
  meta=template.meta,
189
206
  )
190
207
  )
@@ -197,13 +214,25 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
197
214
  "logging": {},
198
215
  }
199
216
 
217
+ # Extract server-level icons and website_url
218
+ server_icons = (
219
+ [icon.model_dump() for icon in mcp._mcp_server.icons]
220
+ if hasattr(mcp._mcp_server, "icons") and mcp._mcp_server.icons
221
+ else None
222
+ )
223
+ server_website_url = (
224
+ mcp._mcp_server.website_url if hasattr(mcp._mcp_server, "website_url") else None
225
+ )
226
+
200
227
  return FastMCPInfo(
201
228
  name=mcp.name,
202
229
  instructions=mcp.instructions,
230
+ version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version),
231
+ website_url=server_website_url,
232
+ icons=server_icons,
203
233
  fastmcp_version=fastmcp.__version__,
204
234
  mcp_version=importlib.metadata.version("mcp"),
205
235
  server_generation=2, # FastMCP v2
206
- version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version),
207
236
  tools=tool_infos,
208
237
  prompts=prompt_infos,
209
238
  resources=resource_infos,
@@ -248,6 +277,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
248
277
  tags=None, # v1 doesn't have tags
249
278
  enabled=None, # v1 doesn't have enabled field
250
279
  title=None, # v1 doesn't have title
280
+ icons=[icon.model_dump() for icon in mcp_tool.icons]
281
+ if hasattr(mcp_tool, "icons") and mcp_tool.icons
282
+ else None,
251
283
  meta=None, # v1 doesn't have meta field
252
284
  )
253
285
  )
@@ -269,6 +301,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
269
301
  tags=None, # v1 doesn't have tags
270
302
  enabled=None, # v1 doesn't have enabled field
271
303
  title=None, # v1 doesn't have title
304
+ icons=[icon.model_dump() for icon in mcp_prompt.icons]
305
+ if hasattr(mcp_prompt, "icons") and mcp_prompt.icons
306
+ else None,
272
307
  meta=None, # v1 doesn't have meta field
273
308
  )
274
309
  )
@@ -287,6 +322,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
287
322
  tags=None, # v1 doesn't have tags
288
323
  enabled=None, # v1 doesn't have enabled field
289
324
  title=None, # v1 doesn't have title
325
+ icons=[icon.model_dump() for icon in mcp_resource.icons]
326
+ if hasattr(mcp_resource, "icons") and mcp_resource.icons
327
+ else None,
290
328
  meta=None, # v1 doesn't have meta field
291
329
  )
292
330
  )
@@ -306,6 +344,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
306
344
  tags=None, # v1 doesn't have tags
307
345
  enabled=None, # v1 doesn't have enabled field
308
346
  title=None, # v1 doesn't have title
347
+ icons=[icon.model_dump() for icon in mcp_template.icons]
348
+ if hasattr(mcp_template, "icons") and mcp_template.icons
349
+ else None,
309
350
  meta=None, # v1 doesn't have meta field
310
351
  )
311
352
  )
@@ -318,13 +359,26 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
318
359
  "logging": {},
319
360
  }
320
361
 
362
+ # Extract server-level icons and website_url from serverInfo
363
+ server_info = client.initialize_result.serverInfo
364
+ server_icons = (
365
+ [icon.model_dump() for icon in server_info.icons]
366
+ if hasattr(server_info, "icons") and server_info.icons
367
+ else None
368
+ )
369
+ server_website_url = (
370
+ server_info.websiteUrl if hasattr(server_info, "websiteUrl") else None
371
+ )
372
+
321
373
  return FastMCPInfo(
322
374
  name=mcp._mcp_server.name,
323
375
  instructions=mcp._mcp_server.instructions,
376
+ version=mcp._mcp_server.version,
377
+ website_url=server_website_url,
378
+ icons=server_icons,
324
379
  fastmcp_version=fastmcp.__version__, # Version generating this manifest
325
380
  mcp_version=importlib.metadata.version("mcp"),
326
381
  server_generation=1, # MCP v1
327
- version=mcp._mcp_server.version,
328
382
  tools=tool_infos,
329
383
  prompts=prompt_infos,
330
384
  resources=resource_infos,
@@ -369,6 +423,8 @@ async def format_fastmcp_info(info: FastMCPInfo) -> bytes:
369
423
  "name": info.name,
370
424
  "instructions": info.instructions,
371
425
  "version": info.version,
426
+ "website_url": info.website_url,
427
+ "icons": info.icons,
372
428
  "generation": info.server_generation,
373
429
  "capabilities": info.capabilities,
374
430
  },
@@ -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,