fastmcp 2.14.1__py3-none-any.whl → 2.14.3__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.
@@ -304,15 +304,19 @@ def _dict_to_enum_schema(
304
304
  multi_select: If True, use anyOf pattern; if False, use oneOf pattern
305
305
 
306
306
  Returns:
307
- {"oneOf": [{"const": "low", "title": "Low Priority"}, ...]} for single-select
308
- {"anyOf": [{"const": "low", "title": "Low Priority"}, ...]} for multi-select
307
+ {"type": "string", "oneOf": [...]} for single-select
308
+ {"anyOf": [...]} for multi-select (used as array items)
309
309
  """
310
310
  pattern_key = "anyOf" if multi_select else "oneOf"
311
311
  pattern = []
312
312
  for value, metadata in enum_dict.items():
313
313
  title = metadata.get("title", value)
314
314
  pattern.append({"const": value, "title": title})
315
- return {pattern_key: pattern}
315
+
316
+ result: dict[str, Any] = {pattern_key: pattern}
317
+ if not multi_select:
318
+ result["type"] = "string"
319
+ return result
316
320
 
317
321
 
318
322
  def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
@@ -87,7 +87,7 @@ class ErrorHandlingMiddleware(Middleware):
87
87
  return error
88
88
 
89
89
  # Map common exceptions to appropriate MCP error codes
90
- error_type = type(error)
90
+ error_type = type(error.__cause__) if error.__cause__ else type(error)
91
91
 
92
92
  if error_type in (ValueError, TypeError):
93
93
  return McpError(
@@ -64,10 +64,8 @@ class OpenAPITool(Tool):
64
64
  try:
65
65
  # Get base URL from client
66
66
  base_url = (
67
- str(self._client.base_url)
68
- if hasattr(self._client, "base_url") and self._client.base_url
69
- else "http://localhost"
70
- )
67
+ str(self._client.base_url) if hasattr(self._client, "base_url") else ""
68
+ ) or "http://localhost"
71
69
 
72
70
  # Get Headers from client
73
71
  cli_headers = (
fastmcp/server/server.py CHANGED
@@ -105,23 +105,6 @@ if TYPE_CHECKING:
105
105
  logger = get_logger(__name__)
106
106
 
107
107
 
108
- def _create_named_fn_wrapper(fn: Callable[..., Any], name: str) -> Callable[..., Any]:
109
- """Create a wrapper function with a custom __name__ for Docket registration.
110
-
111
- Docket uses fn.__name__ as the key for function registration and lookup.
112
- When mounting servers, we need unique names to avoid collisions between
113
- mounted servers that have identically-named functions.
114
- """
115
- import functools
116
-
117
- @functools.wraps(fn)
118
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
119
- return await fn(*args, **kwargs)
120
-
121
- wrapper.__name__ = name
122
- return wrapper
123
-
124
-
125
108
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
126
109
  Transport = Literal["stdio", "http", "sse", "streamable-http"]
127
110
 
@@ -214,8 +197,9 @@ class FastMCP(Generic[LifespanResultT]):
214
197
  # Resolve server default for background task support
215
198
  self._support_tasks_by_default: bool = tasks if tasks is not None else False
216
199
 
217
- # Docket instance (set during lifespan for cross-task access)
200
+ # Docket and Worker instances (set during lifespan for cross-task access)
218
201
  self._docket = None
202
+ self._worker = None
219
203
 
220
204
  self._additional_http_routes: list[BaseRoute] = []
221
205
  self._mounted_servers: list[MountedServer] = []
@@ -437,7 +421,7 @@ class FastMCP(Generic[LifespanResultT]):
437
421
  isinstance(tool, FunctionTool)
438
422
  and tool.task_config.mode != "forbidden"
439
423
  ):
440
- docket.register(tool.fn)
424
+ docket.register(tool.fn, names=[tool.key])
441
425
 
442
426
  for prompt in self._prompt_manager._prompts.values():
443
427
  if (
@@ -445,21 +429,24 @@ class FastMCP(Generic[LifespanResultT]):
445
429
  and prompt.task_config.mode != "forbidden"
446
430
  ):
447
431
  # task execution requires async fn (validated at creation time)
448
- docket.register(cast(Callable[..., Awaitable[Any]], prompt.fn))
432
+ docket.register(
433
+ cast(Callable[..., Awaitable[Any]], prompt.fn),
434
+ names=[prompt.key],
435
+ )
449
436
 
450
437
  for resource in self._resource_manager._resources.values():
451
438
  if (
452
439
  isinstance(resource, FunctionResource)
453
440
  and resource.task_config.mode != "forbidden"
454
441
  ):
455
- docket.register(resource.fn)
442
+ docket.register(resource.fn, names=[resource.name])
456
443
 
457
444
  for template in self._resource_manager._templates.values():
458
445
  if (
459
446
  isinstance(template, FunctionResourceTemplate)
460
447
  and template.task_config.mode != "forbidden"
461
448
  ):
462
- docket.register(template.fn)
449
+ docket.register(template.fn, names=[template.name])
463
450
 
464
451
  # Also register functions from mounted servers so tasks can
465
452
  # execute in the parent's Docket context
@@ -482,6 +469,8 @@ class FastMCP(Generic[LifespanResultT]):
482
469
 
483
470
  # Create and start Worker
484
471
  async with Worker(docket, **worker_kwargs) as worker: # type: ignore[arg-type]
472
+ # Store on server instance for cross-context access
473
+ self._worker = worker
485
474
  # Set Worker in ContextVar so CurrentWorker can access it
486
475
  worker_token = _current_worker.set(worker)
487
476
  try:
@@ -489,21 +478,16 @@ class FastMCP(Generic[LifespanResultT]):
489
478
  try:
490
479
  yield
491
480
  finally:
492
- # Cancel worker task on exit with timeout to prevent hanging
493
481
  worker_task.cancel()
494
- with suppress(
495
- asyncio.CancelledError, asyncio.TimeoutError
496
- ):
497
- await asyncio.wait_for(worker_task, timeout=2.0)
482
+ with suppress(asyncio.CancelledError):
483
+ await worker_task
498
484
  finally:
499
485
  _current_worker.reset(worker_token)
486
+ self._worker = None
500
487
  finally:
501
- # Reset ContextVar
502
488
  _current_docket.reset(docket_token)
503
- # Clear instance attribute
504
489
  self._docket = None
505
490
  finally:
506
- # Reset server ContextVar
507
491
  _current_server.reset(server_token)
508
492
 
509
493
  async def _register_mounted_server_functions(
@@ -535,8 +519,7 @@ class FastMCP(Generic[LifespanResultT]):
535
519
  fn_name = f"{prefix}_{tool.key}"
536
520
  else:
537
521
  fn_name = tool.key
538
- named_fn = _create_named_fn_wrapper(tool.fn, fn_name)
539
- docket.register(named_fn)
522
+ docket.register(tool.fn, names=[fn_name])
540
523
 
541
524
  # Register prompts with prefixed names
542
525
  for prompt in server._prompt_manager._prompts.values():
@@ -545,10 +528,10 @@ class FastMCP(Generic[LifespanResultT]):
545
528
  and prompt.task_config.mode != "forbidden"
546
529
  ):
547
530
  fn_name = f"{prefix}_{prompt.key}" if prefix else prompt.key
548
- named_fn = _create_named_fn_wrapper(
549
- cast(Callable[..., Awaitable[Any]], prompt.fn), fn_name
531
+ docket.register(
532
+ cast(Callable[..., Awaitable[Any]], prompt.fn),
533
+ names=[fn_name],
550
534
  )
551
- docket.register(named_fn)
552
535
 
553
536
  # Register resources with prefixed names (use name, not key/URI)
554
537
  for resource in server._resource_manager._resources.values():
@@ -557,8 +540,7 @@ class FastMCP(Generic[LifespanResultT]):
557
540
  and resource.task_config.mode != "forbidden"
558
541
  ):
559
542
  fn_name = f"{prefix}_{resource.name}" if prefix else resource.name
560
- named_fn = _create_named_fn_wrapper(resource.fn, fn_name)
561
- docket.register(named_fn)
543
+ docket.register(resource.fn, names=[fn_name])
562
544
 
563
545
  # Register resource templates with prefixed names (use name, not key/URI)
564
546
  for template in server._resource_manager._templates.values():
@@ -567,8 +549,7 @@ class FastMCP(Generic[LifespanResultT]):
567
549
  and template.task_config.mode != "forbidden"
568
550
  ):
569
551
  fn_name = f"{prefix}_{template.name}" if prefix else template.name
570
- named_fn = _create_named_fn_wrapper(template.fn, fn_name)
571
- docket.register(named_fn)
552
+ docket.register(template.fn, names=[fn_name])
572
553
 
573
554
  # Recursively register from nested mounted servers with accumulated prefix
574
555
  for nested in server._mounted_servers:
@@ -584,6 +565,8 @@ class FastMCP(Generic[LifespanResultT]):
584
565
  @asynccontextmanager
585
566
  async def _lifespan_manager(self) -> AsyncIterator[None]:
586
567
  if self._lifespan_result_set:
568
+ # Lifespan already ran - ContextVars will be set by Context.__aenter__
569
+ # at request time, so we just yield here.
587
570
  yield
588
571
  return
589
572
 
@@ -2516,10 +2499,7 @@ class FastMCP(Generic[LifespanResultT]):
2516
2499
  """
2517
2500
  # Display server banner
2518
2501
  if show_banner:
2519
- log_server_banner(
2520
- server=self,
2521
- transport="stdio",
2522
- )
2502
+ log_server_banner(server=self)
2523
2503
 
2524
2504
  with temporary_log_level(log_level):
2525
2505
  async with self._lifespan_manager():
@@ -2582,22 +2562,9 @@ class FastMCP(Generic[LifespanResultT]):
2582
2562
  stateless_http=stateless_http,
2583
2563
  )
2584
2564
 
2585
- # Get the path for the server URL
2586
- server_path = (
2587
- app.state.path.lstrip("/")
2588
- if hasattr(app, "state") and hasattr(app.state, "path")
2589
- else path or ""
2590
- )
2591
-
2592
2565
  # Display server banner
2593
2566
  if show_banner:
2594
- log_server_banner(
2595
- server=self,
2596
- transport=transport,
2597
- host=host,
2598
- port=port,
2599
- path=server_path,
2600
- )
2567
+ log_server_banner(server=self)
2601
2568
  uvicorn_config_from_user = uvicorn_config or {}
2602
2569
 
2603
2570
  config_kwargs: dict[str, Any] = {
@@ -56,6 +56,7 @@ async def handle_tool_as_task(
56
56
  ctx = get_context()
57
57
  session_id = ctx.session_id
58
58
 
59
+ # Get Docket from ContextVar (set by Context.__aenter__ at request time)
59
60
  docket = _current_docket.get()
60
61
  if docket is None:
61
62
  raise McpError(
@@ -72,13 +73,15 @@ async def handle_tool_as_task(
72
73
  tool = await server.get_tool(tool_name)
73
74
 
74
75
  # Store task key mapping and creation timestamp in Redis for protocol handlers
75
- redis_key = f"fastmcp:task:{session_id}:{server_task_id}"
76
- created_at_key = f"fastmcp:task:{session_id}:{server_task_id}:created_at"
76
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
77
+ created_at_key = docket.key(
78
+ f"fastmcp:task:{session_id}:{server_task_id}:created_at"
79
+ )
77
80
  ttl_seconds = int(
78
81
  docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
79
82
  )
80
83
  async with docket.redis() as redis:
81
- await redis.set(redis_key, task_key, ex=ttl_seconds)
84
+ await redis.set(task_meta_key, task_key, ex=ttl_seconds)
82
85
  await redis.set(created_at_key, created_at, ex=ttl_seconds)
83
86
 
84
87
  # Send notifications/tasks/created per SEP-1686 (mandatory)
@@ -165,6 +168,7 @@ async def handle_prompt_as_task(
165
168
  ctx = get_context()
166
169
  session_id = ctx.session_id
167
170
 
171
+ # Get Docket from ContextVar (set by Context.__aenter__ at request time)
168
172
  docket = _current_docket.get()
169
173
  if docket is None:
170
174
  raise McpError(
@@ -181,13 +185,15 @@ async def handle_prompt_as_task(
181
185
  prompt = await server.get_prompt(prompt_name)
182
186
 
183
187
  # Store task key mapping and creation timestamp in Redis for protocol handlers
184
- redis_key = f"fastmcp:task:{session_id}:{server_task_id}"
185
- created_at_key = f"fastmcp:task:{session_id}:{server_task_id}:created_at"
188
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
189
+ created_at_key = docket.key(
190
+ f"fastmcp:task:{session_id}:{server_task_id}:created_at"
191
+ )
186
192
  ttl_seconds = int(
187
193
  docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
188
194
  )
189
195
  async with docket.redis() as redis:
190
- await redis.set(redis_key, task_key, ex=ttl_seconds)
196
+ await redis.set(task_meta_key, task_key, ex=ttl_seconds)
191
197
  await redis.set(created_at_key, created_at, ex=ttl_seconds)
192
198
 
193
199
  # Send notifications/tasks/created per SEP-1686 (mandatory)
@@ -272,12 +278,13 @@ async def handle_resource_as_task(
272
278
  ctx = get_context()
273
279
  session_id = ctx.session_id
274
280
 
281
+ # Get Docket from ContextVar (set by Context.__aenter__ at request time)
275
282
  docket = _current_docket.get()
276
283
  if docket is None:
277
284
  raise McpError(
278
285
  ErrorData(
279
286
  code=INTERNAL_ERROR,
280
- message="Background tasks require Docket",
287
+ message="Background tasks require a running FastMCP server context",
281
288
  )
282
289
  )
283
290
 
@@ -285,13 +292,15 @@ async def handle_resource_as_task(
285
292
  task_key = build_task_key(session_id, server_task_id, "resource", str(uri))
286
293
 
287
294
  # Store task key mapping and creation timestamp in Redis for protocol handlers
288
- redis_key = f"fastmcp:task:{session_id}:{server_task_id}"
289
- created_at_key = f"fastmcp:task:{session_id}:{server_task_id}:created_at"
295
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
296
+ created_at_key = docket.key(
297
+ f"fastmcp:task:{session_id}:{server_task_id}:created_at"
298
+ )
290
299
  ttl_seconds = int(
291
300
  docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
292
301
  )
293
302
  async with docket.redis() as redis:
294
- await redis.set(redis_key, task_key, ex=ttl_seconds)
303
+ await redis.set(task_meta_key, task_key, ex=ttl_seconds)
295
304
  await redis.set(created_at_key, created_at, ex=ttl_seconds)
296
305
 
297
306
  # Send notifications/tasks/created per SEP-1686 (mandatory)
@@ -77,10 +77,12 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR
77
77
  )
78
78
 
79
79
  # Look up full task key and creation timestamp from Redis
80
- redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
81
- created_at_key = f"fastmcp:task:{session_id}:{client_task_id}:created_at"
80
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
81
+ created_at_key = docket.key(
82
+ f"fastmcp:task:{session_id}:{client_task_id}:created_at"
83
+ )
82
84
  async with docket.redis() as redis:
83
- task_key_bytes = await redis.get(redis_key)
85
+ task_key_bytes = await redis.get(task_meta_key)
84
86
  created_at_bytes = await redis.get(created_at_key)
85
87
 
86
88
  task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
@@ -176,9 +178,9 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
176
178
  )
177
179
 
178
180
  # Look up full task key from Redis
179
- redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
181
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
180
182
  async with docket.redis() as redis:
181
- task_key_bytes = await redis.get(redis_key)
183
+ task_key_bytes = await redis.get(task_meta_key)
182
184
 
183
185
  task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
184
186
 
@@ -309,10 +311,12 @@ async def tasks_cancel_handler(
309
311
  )
310
312
 
311
313
  # Look up full task key and creation timestamp from Redis
312
- redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
313
- created_at_key = f"fastmcp:task:{session_id}:{client_task_id}:created_at"
314
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
315
+ created_at_key = docket.key(
316
+ f"fastmcp:task:{session_id}:{client_task_id}:created_at"
317
+ )
314
318
  async with docket.redis() as redis:
315
- task_key_bytes = await redis.get(redis_key)
319
+ task_key_bytes = await redis.get(task_meta_key)
316
320
  created_at_bytes = await redis.get(created_at_key)
317
321
 
318
322
  task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
@@ -70,7 +70,7 @@ async def subscribe_to_task_updates(
70
70
  )
71
71
 
72
72
  except Exception as e:
73
- logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True)
73
+ logger.error(f"subscribe_to_task_updates failed for {task_id}: {e}")
74
74
 
75
75
 
76
76
  async def _send_status_notification(
@@ -101,8 +101,7 @@ async def _send_status_notification(
101
101
  key_parts = parse_task_key(task_key)
102
102
  session_id = key_parts["session_id"]
103
103
 
104
- # Retrieve createdAt timestamp from Redis
105
- created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
104
+ created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at")
106
105
  async with docket.redis() as redis:
107
106
  created_at_bytes = await redis.get(created_at_key)
108
107
 
@@ -175,8 +174,7 @@ async def _send_progress_notification(
175
174
  key_parts = parse_task_key(task_key)
176
175
  session_id = key_parts["session_id"]
177
176
 
178
- # Retrieve createdAt timestamp from Redis
179
- created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
177
+ created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at")
180
178
  async with docket.redis() as redis:
181
179
  created_at_bytes = await redis.get(created_at_key)
182
180
 
fastmcp/settings.py CHANGED
@@ -392,6 +392,21 @@ class Settings(BaseSettings):
392
392
  ),
393
393
  ] = True
394
394
 
395
+ check_for_updates: Annotated[
396
+ Literal["stable", "prerelease", "off"],
397
+ Field(
398
+ description=inspect.cleandoc(
399
+ """
400
+ Controls update checking when displaying the CLI banner.
401
+ - "stable": Check for stable releases only (default)
402
+ - "prerelease": Also check for pre-release versions (alpha, beta, rc)
403
+ - "off": Disable update checking entirely
404
+ Set via FASTMCP_CHECK_FOR_UPDATES environment variable.
405
+ """
406
+ ),
407
+ ),
408
+ ] = "stable"
409
+
395
410
  @property
396
411
  def server_auth_class(self) -> AuthProvider | None:
397
412
  from fastmcp.utilities.types import get_cached_typeadapter
fastmcp/tools/tool.py CHANGED
@@ -32,7 +32,7 @@ import fastmcp
32
32
  from fastmcp.server.dependencies import get_context, without_injected_parameters
33
33
  from fastmcp.server.tasks.config import TaskConfig
34
34
  from fastmcp.utilities.components import FastMCPComponent
35
- from fastmcp.utilities.json_schema import compress_schema
35
+ from fastmcp.utilities.json_schema import compress_schema, resolve_root_ref
36
36
  from fastmcp.utilities.logging import get_logger
37
37
  from fastmcp.utilities.types import (
38
38
  Audio,
@@ -559,6 +559,10 @@ class ParsedFunction:
559
559
 
560
560
  output_schema = compress_schema(output_schema, prune_titles=True)
561
561
 
562
+ # Resolve root-level $ref to meet MCP spec requirement for type: object
563
+ # Self-referential Pydantic models generate schemas with $ref at root
564
+ output_schema = resolve_root_ref(output_schema)
565
+
562
566
  except PydanticSchemaGenerationError as e:
563
567
  if "_UnserializableType" not in str(e):
564
568
  logger.debug(f"Unable to generate schema for type {output_type!r}")
@@ -8,7 +8,7 @@ from mcp.types import ToolAnnotations
8
8
  from pydantic import ValidationError
9
9
 
10
10
  from fastmcp import settings
11
- from fastmcp.exceptions import NotFoundError, ToolError
11
+ from fastmcp.exceptions import FastMCPError, NotFoundError, ToolError
12
12
  from fastmcp.settings import DuplicateBehavior
13
13
  from fastmcp.tools.tool import Tool, ToolResult
14
14
  from fastmcp.tools.tool_transform import (
@@ -158,12 +158,10 @@ class ToolManager:
158
158
  tool = await self.get_tool(key)
159
159
  try:
160
160
  return await tool.run(arguments)
161
- except ValidationError as e:
162
- logger.exception(f"Error validating tool {key!r}: {e}")
163
- raise e
164
- except ToolError as e:
165
- logger.exception(f"Error calling tool {key!r}")
166
- raise e
161
+ except FastMCPError:
162
+ raise
163
+ except ValidationError:
164
+ raise
167
165
  except Exception as e:
168
166
  logger.exception(f"Error calling tool {key!r}")
169
167
  if self.mask_error_details:
fastmcp/utilities/cli.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, Literal
6
+ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from pydantic import ValidationError
9
9
  from rich.align import Align
@@ -17,6 +17,7 @@ from fastmcp.utilities.logging import get_logger
17
17
  from fastmcp.utilities.mcp_server_config import MCPServerConfig
18
18
  from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
19
19
  from fastmcp.utilities.types import get_cached_typeadapter
20
+ from fastmcp.utilities.version_check import check_for_newer_version
20
21
 
21
22
  if TYPE_CHECKING:
22
23
  from fastmcp import FastMCP
@@ -197,23 +198,11 @@ LOGO_ASCII_4 = (
197
198
  )
198
199
 
199
200
 
200
- def log_server_banner(
201
- server: FastMCP[Any],
202
- transport: Literal["stdio", "http", "sse", "streamable-http"],
203
- *,
204
- host: str | None = None,
205
- port: int | None = None,
206
- path: str | None = None,
207
- ) -> None:
208
- """Creates and logs a formatted banner with server information and logo.
201
+ def log_server_banner(server: FastMCP[Any]) -> None:
202
+ """Creates and logs a formatted banner with server information and logo."""
209
203
 
210
- Args:
211
- transport: The transport protocol being used
212
- server_name: Optional server name to display
213
- host: Host address (for HTTP transports)
214
- port: Port number (for HTTP transports)
215
- path: Server path (for HTTP transports)
216
- """
204
+ # Check for updates (non-blocking, fails silently)
205
+ newer_version = check_for_newer_version()
217
206
 
218
207
  # Create the logo text
219
208
  # Use Text with no_wrap and markup disabled to preserve ANSI escape codes
@@ -228,39 +217,36 @@ def log_server_banner(
228
217
  info_table.add_column(style="cyan", justify="left") # Label column
229
218
  info_table.add_column(style="dim", justify="left") # Value column
230
219
 
231
- match transport:
232
- case "http" | "streamable-http":
233
- display_transport = "HTTP"
234
- case "sse":
235
- display_transport = "SSE"
236
- case "stdio":
237
- display_transport = "STDIO"
238
-
239
- info_table.add_row("🖥", "Server name:", Text(server.name + "\n", style="bold blue"))
240
- info_table.add_row("📦", "Transport:", display_transport)
241
-
242
- # Show connection info based on transport
243
- if transport in ("http", "streamable-http", "sse") and host and port:
244
- server_url = f"http://{host}:{port}"
245
- if path:
246
- server_url += f"/{path.lstrip('/')}"
247
- info_table.add_row("🔗", "Server URL:", server_url)
248
-
249
- # Add documentation link
250
- info_table.add_row("", "", "")
251
- info_table.add_row("📚", "Docs:", "https://gofastmcp.com")
252
- info_table.add_row("🚀", "Hosting:", "https://fastmcp.cloud")
220
+ info_table.add_row("🖥", "Server:", Text(server.name, style="dim"))
221
+ info_table.add_row("🚀", "Deploy free:", "https://fastmcp.cloud")
253
222
 
254
223
  # Create panel with logo, title, and information using Group
224
+ docs_url = Text("https://gofastmcp.com", style="dim")
255
225
  panel_content = Group(
226
+ "",
256
227
  Align.center(logo_text),
257
228
  "",
258
- Align.center(title_text),
259
229
  "",
230
+ Align.center(title_text),
231
+ Align.center(docs_url),
260
232
  "",
261
233
  Align.center(info_table),
262
234
  )
263
235
 
236
+ # v3 notice banner (shown below main panel)
237
+ v3_line1 = Text("✨ FastMCP 3.0 is coming!", style="bold")
238
+ v3_line2 = Text.assemble(
239
+ ("Pin ", "dim"),
240
+ ("`fastmcp < 3`", "dim bold"),
241
+ (" in production, then upgrade when you're ready.", "dim"),
242
+ )
243
+ v3_notice = Panel(
244
+ Group(Align.center(v3_line1), Align.center(v3_line2)),
245
+ border_style="blue",
246
+ padding=(0, 2),
247
+ width=80,
248
+ )
249
+
264
250
  panel = Panel(
265
251
  panel_content,
266
252
  border_style="dim",
@@ -270,5 +256,26 @@ def log_server_banner(
270
256
  )
271
257
 
272
258
  console = Console(stderr=True)
273
- # Center the panel itself
274
- console.print(Group("\n", Align.center(panel), "\n"))
259
+
260
+ # Build output elements
261
+ output_elements: list[Align | Panel | str] = ["\n", Align.center(panel)]
262
+ output_elements.append(Align.center(v3_notice))
263
+
264
+ # Add update notice if a newer version is available (shown last for visibility)
265
+ if newer_version:
266
+ update_line1 = Text.assemble(
267
+ ("🎉 Update available: ", "bold"),
268
+ (newer_version, "bold green"),
269
+ )
270
+ update_line2 = Text("Run: pip install --upgrade fastmcp", style="dim")
271
+ update_notice = Panel(
272
+ Group(Align.center(update_line1), Align.center(update_line2)),
273
+ border_style="blue",
274
+ padding=(0, 2),
275
+ width=80,
276
+ )
277
+ output_elements.append(Align.center(update_notice))
278
+
279
+ output_elements.append("\n")
280
+
281
+ console.print(Group(*output_elements))
@@ -1,6 +1,46 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections import defaultdict
4
+ from typing import Any
5
+
6
+
7
+ def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
8
+ """Resolve $ref at root level to meet MCP spec requirements.
9
+
10
+ MCP specification requires outputSchema to have "type": "object" at the root level.
11
+ When Pydantic generates schemas for self-referential models, it uses $ref at the
12
+ root level pointing to $defs. This function resolves such references by inlining
13
+ the referenced definition while preserving $defs for nested references.
14
+
15
+ Args:
16
+ schema: JSON schema dict that may have $ref at root level
17
+
18
+ Returns:
19
+ A new schema dict with root-level $ref resolved, or the original schema
20
+ if no resolution is needed
21
+
22
+ Example:
23
+ >>> schema = {
24
+ ... "$defs": {"Node": {"type": "object", "properties": {...}}},
25
+ ... "$ref": "#/$defs/Node"
26
+ ... }
27
+ >>> resolved = resolve_root_ref(schema)
28
+ >>> # Result: {"type": "object", "properties": {...}, "$defs": {...}}
29
+ """
30
+ # Only resolve if we have $ref at root level with $defs but no explicit type
31
+ if "$ref" in schema and "$defs" in schema and "type" not in schema:
32
+ ref = schema["$ref"]
33
+ # Only handle local $defs references
34
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
35
+ def_name = ref.split("/")[-1]
36
+ defs = schema["$defs"]
37
+ if def_name in defs:
38
+ # Create a new schema by copying the referenced definition
39
+ resolved = dict(defs[def_name])
40
+ # Preserve $defs for nested references (other fields may still use them)
41
+ resolved["$defs"] = defs
42
+ return resolved
43
+ return schema
4
44
 
5
45
 
6
46
  def _prune_param(schema: dict, param: str) -> dict: