fastmcp 2.11.2__py3-none-any.whl → 2.12.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 (77) hide show
  1. fastmcp/__init__.py +5 -4
  2. fastmcp/cli/claude.py +22 -18
  3. fastmcp/cli/cli.py +472 -136
  4. fastmcp/cli/install/claude_code.py +37 -40
  5. fastmcp/cli/install/claude_desktop.py +37 -42
  6. fastmcp/cli/install/cursor.py +148 -38
  7. fastmcp/cli/install/mcp_json.py +38 -43
  8. fastmcp/cli/install/shared.py +64 -7
  9. fastmcp/cli/run.py +122 -215
  10. fastmcp/client/auth/oauth.py +69 -13
  11. fastmcp/client/client.py +46 -9
  12. fastmcp/client/logging.py +25 -1
  13. fastmcp/client/oauth_callback.py +91 -91
  14. fastmcp/client/sampling.py +12 -4
  15. fastmcp/client/transports.py +143 -67
  16. fastmcp/experimental/sampling/__init__.py +0 -0
  17. fastmcp/experimental/sampling/handlers/__init__.py +3 -0
  18. fastmcp/experimental/sampling/handlers/base.py +21 -0
  19. fastmcp/experimental/sampling/handlers/openai.py +163 -0
  20. fastmcp/experimental/server/openapi/routing.py +1 -3
  21. fastmcp/experimental/server/openapi/server.py +10 -25
  22. fastmcp/experimental/utilities/openapi/__init__.py +2 -2
  23. fastmcp/experimental/utilities/openapi/formatters.py +34 -0
  24. fastmcp/experimental/utilities/openapi/models.py +5 -2
  25. fastmcp/experimental/utilities/openapi/parser.py +252 -70
  26. fastmcp/experimental/utilities/openapi/schemas.py +135 -106
  27. fastmcp/mcp_config.py +40 -20
  28. fastmcp/prompts/prompt_manager.py +4 -2
  29. fastmcp/resources/resource_manager.py +16 -6
  30. fastmcp/server/auth/__init__.py +11 -1
  31. fastmcp/server/auth/auth.py +19 -2
  32. fastmcp/server/auth/oauth_proxy.py +1047 -0
  33. fastmcp/server/auth/providers/azure.py +270 -0
  34. fastmcp/server/auth/providers/github.py +287 -0
  35. fastmcp/server/auth/providers/google.py +305 -0
  36. fastmcp/server/auth/providers/jwt.py +27 -16
  37. fastmcp/server/auth/providers/workos.py +256 -2
  38. fastmcp/server/auth/redirect_validation.py +65 -0
  39. fastmcp/server/auth/registry.py +1 -1
  40. fastmcp/server/context.py +91 -41
  41. fastmcp/server/dependencies.py +32 -2
  42. fastmcp/server/elicitation.py +60 -1
  43. fastmcp/server/http.py +44 -37
  44. fastmcp/server/middleware/logging.py +66 -28
  45. fastmcp/server/proxy.py +2 -0
  46. fastmcp/server/sampling/handler.py +19 -0
  47. fastmcp/server/server.py +85 -20
  48. fastmcp/settings.py +18 -3
  49. fastmcp/tools/tool.py +23 -10
  50. fastmcp/tools/tool_manager.py +5 -1
  51. fastmcp/tools/tool_transform.py +75 -32
  52. fastmcp/utilities/auth.py +34 -0
  53. fastmcp/utilities/cli.py +148 -15
  54. fastmcp/utilities/components.py +21 -5
  55. fastmcp/utilities/inspect.py +166 -37
  56. fastmcp/utilities/json_schema_type.py +4 -2
  57. fastmcp/utilities/logging.py +4 -1
  58. fastmcp/utilities/mcp_config.py +47 -18
  59. fastmcp/utilities/mcp_server_config/__init__.py +25 -0
  60. fastmcp/utilities/mcp_server_config/v1/__init__.py +0 -0
  61. fastmcp/utilities/mcp_server_config/v1/environments/__init__.py +6 -0
  62. fastmcp/utilities/mcp_server_config/v1/environments/base.py +30 -0
  63. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +306 -0
  64. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +446 -0
  65. fastmcp/utilities/mcp_server_config/v1/schema.json +361 -0
  66. fastmcp/utilities/mcp_server_config/v1/sources/__init__.py +0 -0
  67. fastmcp/utilities/mcp_server_config/v1/sources/base.py +30 -0
  68. fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +216 -0
  69. fastmcp/utilities/openapi.py +4 -4
  70. fastmcp/utilities/tests.py +7 -2
  71. fastmcp/utilities/types.py +15 -2
  72. {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/METADATA +3 -2
  73. fastmcp-2.12.0.dist-info/RECORD +129 -0
  74. fastmcp-2.11.2.dist-info/RECORD +0 -108
  75. {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/WHEEL +0 -0
  76. {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/entry_points.txt +0 -0
  77. {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
+ import warnings
4
5
  from collections.abc import Callable
5
6
  from contextvars import ContextVar
6
7
  from dataclasses import dataclass
7
- from typing import Annotated, Any, Literal
8
+ from typing import Annotated, Any, Literal, cast
8
9
 
10
+ import pydantic_core
9
11
  from mcp.types import ToolAnnotations
10
12
  from pydantic import ConfigDict
11
13
  from pydantic.fields import Field
12
14
  from pydantic.functional_validators import BeforeValidator
13
15
 
16
+ import fastmcp
14
17
  from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _convert_to_content
15
18
  from fastmcp.utilities.components import _convert_set_default_none
16
19
  from fastmcp.utilities.json_schema import compress_schema
@@ -26,7 +29,7 @@ logger = get_logger(__name__)
26
29
 
27
30
 
28
31
  # Context variable to store current transformed tool
29
- _current_tool: ContextVar[TransformedTool | None] = ContextVar(
32
+ _current_tool: ContextVar[TransformedTool | None] = ContextVar( # type: ignore[assignment]
30
33
  "_current_tool", default=None
31
34
  )
32
35
 
@@ -312,11 +315,9 @@ class TransformedTool(Tool):
312
315
  # Custom function returns ToolResult - preserve its content
313
316
  return result
314
317
  else:
315
- # Forwarded call with disabled schema - strip structured content
316
- return ToolResult(
317
- content=result.content,
318
- structured_content=None,
319
- )
318
+ # Forwarded call with no explicit schema - preserve parent's structured content
319
+ # The parent tool may have generated structured content via its own fallback logic
320
+ return result
320
321
  elif self.output_schema.get(
321
322
  "type"
322
323
  ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
@@ -334,17 +335,23 @@ class TransformedTool(Tool):
334
335
  result, serializer=self.serializer
335
336
  )
336
337
 
337
- # Handle structured content based on output schema
338
+ structured_output = None
339
+ # First handle structured content based on output schema, if any
338
340
  if self.output_schema is not None:
339
341
  if self.output_schema.get("x-fastmcp-wrap-result"):
340
342
  # Schema says wrap - always wrap in result key
341
343
  structured_output = {"result": result}
342
344
  else:
343
- # Object schemas - use result directly
344
- # User is responsible for returning dict-compatible data
345
345
  structured_output = result
346
- else:
347
- structured_output = None
346
+ # If no output schema, try to serialize the result. If it is a dict, use
347
+ # it as structured content. If it is not a dict, ignore it.
348
+ if structured_output is None:
349
+ try:
350
+ structured_output = pydantic_core.to_jsonable_python(result)
351
+ if not isinstance(structured_output, dict):
352
+ structured_output = None
353
+ except Exception:
354
+ pass
348
355
 
349
356
  return ToolResult(
350
357
  content=unstructured_result,
@@ -363,9 +370,9 @@ class TransformedTool(Tool):
363
370
  tags: set[str] | None = None,
364
371
  transform_fn: Callable[..., Any] | None = None,
365
372
  transform_args: dict[str, ArgTransform] | None = None,
366
- annotations: ToolAnnotations | None = None,
367
- output_schema: dict[str, Any] | None | Literal[False] = None,
368
- serializer: Callable[[Any], str] | None = None,
373
+ annotations: ToolAnnotations | None | NotSetT = NotSet,
374
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
375
+ serializer: Callable[[Any], str] | None | NotSetT = NotSet,
369
376
  meta: dict[str, Any] | None | NotSetT = NotSet,
370
377
  enabled: bool | None = None,
371
378
  ) -> TransformedTool:
@@ -432,7 +439,7 @@ class TransformedTool(Tool):
432
439
  })
433
440
 
434
441
  # Disable structured outputs
435
- Tool.from_tool(parent, output_schema=False)
442
+ Tool.from_tool(parent, output_schema=None)
436
443
 
437
444
  # Return ToolResult for full control
438
445
  async def custom_output(**kwargs) -> ToolResult:
@@ -445,6 +452,11 @@ class TransformedTool(Tool):
445
452
  """
446
453
  transform_args = transform_args or {}
447
454
 
455
+ if transform_fn is not None:
456
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
457
+ else:
458
+ parsed_fn = None
459
+
448
460
  # Validate transform_args
449
461
  parent_params = set(tool.parameters.get("properties", {}).keys())
450
462
  unknown_args = set(transform_args.keys()) - parent_params
@@ -457,17 +469,12 @@ class TransformedTool(Tool):
457
469
  # Always create the forwarding transform
458
470
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
459
471
 
460
- # Handle output schema with smart fallback
461
- if output_schema is False:
462
- final_output_schema = None
463
- elif output_schema is not None:
464
- # Explicit schema provided - use as-is
465
- final_output_schema = output_schema
466
- else:
467
- # Smart fallback: try custom function, then parent, then None
472
+ # Handle output schema
473
+ if output_schema is NotSet:
474
+ # Use smart fallback: try custom function, then parent
468
475
  if transform_fn is not None:
469
- parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
470
- final_output_schema = parsed_fn.output_schema
476
+ # parsed fn is not none here
477
+ final_output_schema = cast(ParsedFunction, parsed_fn).output_schema
471
478
  if final_output_schema is None:
472
479
  # Check if function returns ToolResult - if so, don't fall back to parent
473
480
  return_annotation = inspect.signature(
@@ -479,15 +486,26 @@ class TransformedTool(Tool):
479
486
  final_output_schema = tool.output_schema
480
487
  else:
481
488
  final_output_schema = tool.output_schema
489
+ elif output_schema is False:
490
+ # Handle False as deprecated synonym for None (deprecated in 2.11.4)
491
+ if fastmcp.settings.deprecation_warnings:
492
+ warnings.warn(
493
+ "Passing output_schema=False is deprecated. Use output_schema=None instead.",
494
+ DeprecationWarning,
495
+ stacklevel=2,
496
+ )
497
+ final_output_schema = None
498
+ else:
499
+ final_output_schema = cast(dict | None, output_schema)
482
500
 
483
501
  if transform_fn is None:
484
502
  # User wants pure transformation - use forwarding_fn as the main function
485
503
  final_fn = forwarding_fn
486
504
  final_schema = schema
487
505
  else:
506
+ # parsed fn is not none here
507
+ parsed_fn = cast(ParsedFunction, parsed_fn)
488
508
  # User provided custom function - merge schemas
489
- if "parsed_fn" not in locals():
490
- parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
491
509
  final_fn = transform_fn
492
510
 
493
511
  has_kwargs = cls._function_has_kwargs(transform_fn)
@@ -552,6 +570,13 @@ class TransformedTool(Tool):
552
570
  )
553
571
  final_title = title if not isinstance(title, NotSetT) else tool.title
554
572
  final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
573
+ final_annotations = (
574
+ annotations if not isinstance(annotations, NotSetT) else tool.annotations
575
+ )
576
+ final_serializer = (
577
+ serializer if not isinstance(serializer, NotSetT) else tool.serializer
578
+ )
579
+ final_enabled = enabled if enabled is not None else tool.enabled
555
580
 
556
581
  transformed_tool = cls(
557
582
  fn=final_fn,
@@ -563,11 +588,11 @@ class TransformedTool(Tool):
563
588
  parameters=final_schema,
564
589
  output_schema=final_output_schema,
565
590
  tags=tags or tool.tags,
566
- annotations=annotations or tool.annotations,
567
- serializer=serializer or tool.serializer,
591
+ annotations=final_annotations,
592
+ serializer=final_serializer,
568
593
  meta=final_meta,
569
594
  transform_args=transform_args,
570
- enabled=enabled if enabled is not None else True,
595
+ enabled=final_enabled,
571
596
  )
572
597
 
573
598
  return transformed_tool
@@ -816,12 +841,30 @@ class TransformedTool(Tool):
816
841
  if "default" in param_schema:
817
842
  final_required.discard(param_name)
818
843
 
819
- return {
844
+ # Merge $defs from both schemas, with override taking precedence
845
+ merged_defs = base_schema.get("$defs", {}).copy()
846
+ override_defs = override_schema.get("$defs", {})
847
+
848
+ for def_name, def_schema in override_defs.items():
849
+ if def_name in merged_defs:
850
+ base_def = merged_defs[def_name].copy()
851
+ base_def.update(def_schema)
852
+ merged_defs[def_name] = base_def
853
+ else:
854
+ merged_defs[def_name] = def_schema.copy()
855
+
856
+ result = {
820
857
  "type": "object",
821
858
  "properties": merged_props,
822
859
  "required": list(final_required),
823
860
  }
824
861
 
862
+ if merged_defs:
863
+ result["$defs"] = merged_defs
864
+ result = compress_schema(result, prune_defs=True)
865
+
866
+ return result
867
+
825
868
  @staticmethod
826
869
  def _function_has_kwargs(fn: Callable[..., Any]) -> bool:
827
870
  """Check if function accepts **kwargs.
@@ -0,0 +1,34 @@
1
+ """Authentication utility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def parse_scopes(value: Any) -> list[str] | None:
10
+ """Parse scopes from environment variables or settings values.
11
+
12
+ Accepts either a JSON array string, a comma- or space-separated string,
13
+ a list of strings, or ``None``. Returns a list of scopes or ``None`` if
14
+ no value is provided.
15
+ """
16
+ if value is None or value == "":
17
+ return None if value is None else []
18
+ if isinstance(value, list):
19
+ return [str(v).strip() for v in value if str(v).strip()]
20
+ if isinstance(value, str):
21
+ value = value.strip()
22
+ if not value:
23
+ return []
24
+ # Try JSON array first
25
+ if value.startswith("["):
26
+ try:
27
+ data = json.loads(value)
28
+ if isinstance(data, list):
29
+ return [str(v).strip() for v in data if str(v).strip()]
30
+ except Exception:
31
+ pass
32
+ # Fallback to comma/space separated list
33
+ return [s.strip() for s in value.replace(",", " ").split() if s.strip()]
34
+ return value
fastmcp/utilities/cli.py CHANGED
@@ -1,24 +1,149 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import os
3
5
  from importlib.metadata import version
6
+ from pathlib import Path
4
7
  from typing import TYPE_CHECKING, Any, Literal
5
8
 
9
+ from pydantic import ValidationError
10
+ from rich.align import Align
6
11
  from rich.console import Console, Group
7
12
  from rich.panel import Panel
8
13
  from rich.table import Table
9
14
  from rich.text import Text
10
15
 
11
16
  import fastmcp
17
+ from fastmcp.utilities.logging import get_logger
18
+ from fastmcp.utilities.mcp_server_config import MCPServerConfig
19
+ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
20
+ from fastmcp.utilities.types import get_cached_typeadapter
12
21
 
13
22
  if TYPE_CHECKING:
14
23
  from fastmcp import FastMCP
15
24
 
25
+ logger = get_logger("cli.config")
26
+
27
+
28
+ def is_already_in_uv_subprocess() -> bool:
29
+ """Check if we're already running in a FastMCP uv subprocess."""
30
+ return bool(os.environ.get("FASTMCP_UV_SPAWNED"))
31
+
32
+
33
+ def load_and_merge_config(
34
+ server_spec: str | None,
35
+ **cli_overrides,
36
+ ) -> tuple[MCPServerConfig, str]:
37
+ """Load config from server_spec and apply CLI overrides.
38
+
39
+ This consolidates the config parsing logic that was duplicated across
40
+ run, inspect, and dev commands.
41
+
42
+ Args:
43
+ server_spec: Python file, config file, URL, or None to auto-detect
44
+ cli_overrides: CLI arguments that override config values
45
+
46
+ Returns:
47
+ Tuple of (MCPServerConfig, resolved_server_spec)
48
+ """
49
+ config = None
50
+ config_path = None
51
+
52
+ # Auto-detect fastmcp.json if no server_spec provided
53
+ if server_spec is None:
54
+ config_path = Path("fastmcp.json")
55
+ if not config_path.exists():
56
+ found_config = MCPServerConfig.find_config()
57
+ if found_config:
58
+ config_path = found_config
59
+ else:
60
+ logger.error(
61
+ "No server specification provided and no fastmcp.json found in current directory.\n"
62
+ "Please specify a server file or create a fastmcp.json configuration."
63
+ )
64
+ raise FileNotFoundError("No server specification or fastmcp.json found")
65
+
66
+ resolved_spec = str(config_path)
67
+ logger.info(f"Using configuration from {config_path}")
68
+ else:
69
+ resolved_spec = server_spec
70
+
71
+ # Load config if server_spec is a .json file
72
+ if resolved_spec.endswith(".json"):
73
+ config_path = Path(resolved_spec)
74
+ if config_path.exists():
75
+ try:
76
+ with open(config_path) as f:
77
+ data = json.load(f)
78
+
79
+ # Check if it's an MCPConfig first (has canonical mcpServers key)
80
+ if "mcpServers" in data:
81
+ # MCPConfig - we don't process these here, just pass through
82
+ pass
83
+ else:
84
+ # Try to parse as MCPServerConfig
85
+ try:
86
+ adapter = get_cached_typeadapter(MCPServerConfig)
87
+ config = adapter.validate_python(data)
88
+
89
+ # Apply deployment settings
90
+ if config.deployment:
91
+ config.deployment.apply_runtime_settings(config_path)
92
+
93
+ except ValidationError:
94
+ # Not a valid MCPServerConfig, just pass through
95
+ pass
96
+ except (json.JSONDecodeError, FileNotFoundError):
97
+ # Not a valid JSON file, just pass through
98
+ pass
99
+
100
+ # If we don't have a config object yet, create one from filesystem source
101
+ if config is None:
102
+ source = FileSystemSource(path=resolved_spec)
103
+ config = MCPServerConfig(source=source)
104
+
105
+ # Convert to dict for immutable transformation
106
+ config_dict = config.model_dump()
107
+
108
+ # Apply CLI overrides to config's environment (always exists due to default_factory)
109
+ if python_override := cli_overrides.get("python"):
110
+ config_dict["environment"]["python"] = python_override
111
+ if packages_override := cli_overrides.get("with_packages"):
112
+ # Merge packages - CLI packages are added to config packages
113
+ existing = config_dict["environment"].get("dependencies") or []
114
+ config_dict["environment"]["dependencies"] = packages_override + existing
115
+ if requirements_override := cli_overrides.get("with_requirements"):
116
+ config_dict["environment"]["requirements"] = str(requirements_override)
117
+ if project_override := cli_overrides.get("project"):
118
+ config_dict["environment"]["project"] = str(project_override)
119
+ if editable_override := cli_overrides.get("editable"):
120
+ config_dict["environment"]["editable"] = editable_override
121
+
122
+ # Apply deployment CLI overrides (always exists due to default_factory)
123
+ if transport_override := cli_overrides.get("transport"):
124
+ config_dict["deployment"]["transport"] = transport_override
125
+ if host_override := cli_overrides.get("host"):
126
+ config_dict["deployment"]["host"] = host_override
127
+ if port_override := cli_overrides.get("port"):
128
+ config_dict["deployment"]["port"] = port_override
129
+ if path_override := cli_overrides.get("path"):
130
+ config_dict["deployment"]["path"] = path_override
131
+ if log_level_override := cli_overrides.get("log_level"):
132
+ config_dict["deployment"]["log_level"] = log_level_override
133
+ if server_args_override := cli_overrides.get("server_args"):
134
+ config_dict["deployment"]["args"] = server_args_override
135
+
136
+ # Create new config from modified dict
137
+ new_config = MCPServerConfig(**config_dict)
138
+ return new_config, resolved_spec
139
+
140
+
16
141
  LOGO_ASCII = r"""
17
- _ __ ___ ______ __ __ _____________ ____ ____
18
- _ __ ___ / ____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \
142
+ _ __ ___ _____ __ __ _____________ ____ ____
143
+ _ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \
19
144
  _ __ ___ / /_ / __ `/ ___/ __/ /|_/ / / / /_/ / ___/ / / / / /
20
145
  _ __ ___ / __/ / /_/ (__ ) /_/ / / / /___/ ____/ / __/_/ /_/ /
21
- _ __ ___ /_/ \__,_/____/\__/_/ /_/\____/_/ /_____(_)____/
146
+ _ __ ___ /_/ \____/____/\__/_/ /_/\____/_/ /_____(*)____/
22
147
 
23
148
  """.lstrip("\n")
24
149
 
@@ -44,11 +169,14 @@ def log_server_banner(
44
169
  # Create the logo text
45
170
  logo_text = Text(LOGO_ASCII, style="bold green")
46
171
 
172
+ # Create the main title
173
+ title_text = Text("FastMCP 2.0", style="bold blue")
174
+
47
175
  # Create the information table
48
176
  info_table = Table.grid(padding=(0, 1))
49
177
  info_table.add_column(style="bold", justify="center") # Emoji column
50
- info_table.add_column(style="bold cyan", justify="left") # Label column
51
- info_table.add_column(style="white", justify="left") # Value column
178
+ info_table.add_column(style="cyan", justify="left") # Label column
179
+ info_table.add_column(style="dim", justify="left") # Value column
52
180
 
53
181
  match transport:
54
182
  case "http" | "streamable-http":
@@ -69,11 +197,6 @@ def log_server_banner(
69
197
  server_url += f"/{path.lstrip('/')}"
70
198
  info_table.add_row("🔗", "Server URL:", server_url)
71
199
 
72
- # Add documentation link
73
- info_table.add_row("", "", "")
74
- info_table.add_row("📚", "Docs:", "https://gofastmcp.com")
75
- info_table.add_row("🚀", "Deploy:", "https://fastmcp.cloud")
76
-
77
200
  # Add version information with explicit style overrides
78
201
  info_table.add_row("", "", "")
79
202
  info_table.add_row(
@@ -83,16 +206,26 @@ def log_server_banner(
83
206
  )
84
207
  info_table.add_row(
85
208
  "🤝",
86
- "MCP version:",
209
+ "MCP SDK version:",
87
210
  Text(version("mcp"), style="dim white", no_wrap=True),
88
211
  )
89
- # Create panel with logo and information using Group
90
- panel_content = Group(logo_text, "", info_table)
212
+
213
+ # Add documentation link
214
+ info_table.add_row("", "", "")
215
+ info_table.add_row("📚", "Docs:", "https://gofastmcp.com")
216
+ info_table.add_row("🚀", "Deploy:", "https://fastmcp.cloud")
217
+
218
+ # Create panel with logo, title, and information using Group
219
+ panel_content = Group(
220
+ Align.center(logo_text),
221
+ Align.center(title_text),
222
+ "",
223
+ "",
224
+ Align.center(info_table),
225
+ )
91
226
 
92
227
  panel = Panel(
93
228
  panel_content,
94
- title="FastMCP 2.0",
95
- title_align="left",
96
229
  border_style="dim",
97
230
  padding=(1, 4),
98
231
  expand=False,
@@ -91,18 +91,34 @@ class FastMCPComponent(FastMCPBaseModel):
91
91
 
92
92
  return meta or None
93
93
 
94
- def with_key(self, key: str) -> Self:
94
+ def model_copy(
95
+ self,
96
+ *,
97
+ update: dict[str, Any] | None = None,
98
+ deep: bool = False,
99
+ key: str | None = None,
100
+ ) -> Self:
101
+ """
102
+ Create a copy of the component.
103
+
104
+ Args:
105
+ update: A dictionary of fields to update.
106
+ deep: Whether to deep copy the component.
107
+ key: The key to use for the copy.
108
+ """
95
109
  # `model_copy` has an `update` parameter but it doesn't work for certain private attributes
96
110
  # https://github.com/pydantic/pydantic/issues/12116
97
- # So we manually set the private attribute here instead
98
- copy = self.model_copy()
99
- copy._key = key
111
+ # So we manually set the private attribute here instead, such as _key
112
+ copy = super().model_copy(update=update, deep=deep)
113
+ if key is not None:
114
+ copy._key = key
100
115
  return copy
101
116
 
102
117
  def __eq__(self, other: object) -> bool:
103
118
  if type(self) is not type(other):
104
119
  return False
105
- assert isinstance(other, type(self))
120
+ if not isinstance(other, type(self)):
121
+ return False
106
122
  return self.model_dump() == other.model_dump()
107
123
 
108
124
  def __repr__(self) -> str: