fastmcp 2.12.1__py3-none-any.whl → 2.13.2__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 (109) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +56 -36
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +7 -16
  5. fastmcp/cli/install/claude_desktop.py +4 -12
  6. fastmcp/cli/install/cursor.py +20 -30
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +4 -12
  9. fastmcp/cli/run.py +15 -94
  10. fastmcp/client/__init__.py +9 -9
  11. fastmcp/client/auth/oauth.py +117 -206
  12. fastmcp/client/client.py +123 -47
  13. fastmcp/client/elicitation.py +6 -1
  14. fastmcp/client/logging.py +18 -14
  15. fastmcp/client/oauth_callback.py +85 -171
  16. fastmcp/client/sampling.py +1 -1
  17. fastmcp/client/transports.py +81 -26
  18. fastmcp/contrib/component_manager/__init__.py +1 -1
  19. fastmcp/contrib/component_manager/component_manager.py +2 -2
  20. fastmcp/contrib/component_manager/component_service.py +7 -7
  21. fastmcp/contrib/mcp_mixin/README.md +35 -4
  22. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  23. fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
  24. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  25. fastmcp/experimental/server/openapi/__init__.py +5 -8
  26. fastmcp/experimental/server/openapi/components.py +11 -7
  27. fastmcp/experimental/server/openapi/routing.py +2 -2
  28. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  29. fastmcp/experimental/utilities/openapi/director.py +16 -10
  30. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  31. fastmcp/experimental/utilities/openapi/models.py +3 -3
  32. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  33. fastmcp/experimental/utilities/openapi/schemas.py +33 -7
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +32 -27
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +28 -20
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +119 -27
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -5
  45. fastmcp/server/auth/auth.py +80 -47
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1556 -265
  50. fastmcp/server/auth/oidc_proxy.py +412 -0
  51. fastmcp/server/auth/providers/auth0.py +193 -0
  52. fastmcp/server/auth/providers/aws.py +263 -0
  53. fastmcp/server/auth/providers/azure.py +314 -129
  54. fastmcp/server/auth/providers/bearer.py +1 -1
  55. fastmcp/server/auth/providers/debug.py +114 -0
  56. fastmcp/server/auth/providers/descope.py +229 -0
  57. fastmcp/server/auth/providers/discord.py +308 -0
  58. fastmcp/server/auth/providers/github.py +31 -6
  59. fastmcp/server/auth/providers/google.py +50 -7
  60. fastmcp/server/auth/providers/in_memory.py +27 -3
  61. fastmcp/server/auth/providers/introspection.py +281 -0
  62. fastmcp/server/auth/providers/jwt.py +48 -31
  63. fastmcp/server/auth/providers/oci.py +233 -0
  64. fastmcp/server/auth/providers/scalekit.py +238 -0
  65. fastmcp/server/auth/providers/supabase.py +188 -0
  66. fastmcp/server/auth/providers/workos.py +37 -15
  67. fastmcp/server/context.py +194 -67
  68. fastmcp/server/dependencies.py +56 -16
  69. fastmcp/server/elicitation.py +1 -1
  70. fastmcp/server/http.py +57 -18
  71. fastmcp/server/low_level.py +121 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +158 -116
  76. fastmcp/server/middleware/middleware.py +30 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi.py +15 -7
  80. fastmcp/server/proxy.py +22 -11
  81. fastmcp/server/server.py +744 -254
  82. fastmcp/settings.py +65 -15
  83. fastmcp/tools/__init__.py +1 -1
  84. fastmcp/tools/tool.py +173 -108
  85. fastmcp/tools/tool_manager.py +30 -112
  86. fastmcp/tools/tool_transform.py +13 -11
  87. fastmcp/utilities/cli.py +67 -28
  88. fastmcp/utilities/components.py +7 -2
  89. fastmcp/utilities/inspect.py +79 -23
  90. fastmcp/utilities/json_schema.py +21 -4
  91. fastmcp/utilities/json_schema_type.py +4 -4
  92. fastmcp/utilities/logging.py +182 -10
  93. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  94. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  95. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
  96. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
  97. fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
  98. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  99. fastmcp/utilities/openapi.py +11 -11
  100. fastmcp/utilities/tests.py +93 -10
  101. fastmcp/utilities/types.py +87 -21
  102. fastmcp/utilities/ui.py +626 -0
  103. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
  104. fastmcp-2.13.2.dist-info/RECORD +144 -0
  105. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  106. fastmcp/cli/claude.py +0 -144
  107. fastmcp-2.12.1.dist-info/RECORD +0 -128
  108. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  109. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,5 @@
1
- import os
2
1
  import shutil
3
2
  import subprocess
4
- import sys
5
3
  from pathlib import Path
6
4
  from typing import Literal
7
5
 
@@ -30,19 +28,19 @@ class UVEnvironment(Environment):
30
28
  examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
31
29
  )
32
30
 
33
- requirements: str | None = Field(
31
+ requirements: Path | None = Field(
34
32
  default=None,
35
33
  description="Path to requirements.txt file",
36
34
  examples=["requirements.txt", "../requirements/prod.txt"],
37
35
  )
38
36
 
39
- project: str | None = Field(
37
+ project: Path | None = Field(
40
38
  default=None,
41
39
  description="Path to project directory containing pyproject.toml",
42
40
  examples=[".", "../my-project"],
43
41
  )
44
42
 
45
- editable: list[str] | None = Field(
43
+ editable: list[Path] | None = Field(
46
44
  default=None,
47
45
  description="Directories to install in editable mode",
48
46
  examples=[[".", "../my-package"], ["/path/to/package"]],
@@ -59,14 +57,14 @@ class UVEnvironment(Environment):
59
57
  If no environment configuration is set, returns the command unchanged.
60
58
  """
61
59
  # If no environment setup is needed, return command as-is
62
- if not self._needs_setup():
60
+ if not self._must_run_with_uv():
63
61
  return command
64
62
 
65
63
  args = ["uv", "run"]
66
64
 
67
65
  # Add project if specified
68
66
  if self.project:
69
- args.extend(["--project", str(self.project)])
67
+ args.extend(["--project", str(self.project.resolve())])
70
68
 
71
69
  # Add Python version if specified (only if no project, as project has its own Python)
72
70
  if self.python and not self.project:
@@ -75,48 +73,24 @@ class UVEnvironment(Environment):
75
73
  # Always add dependencies, requirements, and editable packages
76
74
  # These work with --project to add additional packages on top of the project env
77
75
  if self.dependencies:
78
- for dep in self.dependencies:
76
+ for dep in sorted(set(self.dependencies)):
79
77
  args.extend(["--with", dep])
80
78
 
81
79
  # Add requirements file
82
80
  if self.requirements:
83
- args.extend(["--with-requirements", str(self.requirements)])
81
+ args.extend(["--with-requirements", str(self.requirements.resolve())])
84
82
 
85
83
  # Add editable packages
86
84
  if self.editable:
87
85
  for editable_path in self.editable:
88
- args.extend(["--with-editable", str(editable_path)])
86
+ args.extend(["--with-editable", str(editable_path.resolve())])
89
87
 
90
88
  # Add the command
91
89
  args.extend(command)
92
90
 
93
91
  return args
94
92
 
95
- def run_with_uv(self, command: list[str]) -> None:
96
- """Execute a command using uv run with this environment configuration.
97
-
98
- Args:
99
- command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
100
- """
101
- import subprocess
102
-
103
- # Build the full uv command
104
- cmd = self.build_command(command)
105
-
106
- # Set marker to prevent infinite loops when subprocess calls FastMCP again
107
- env = os.environ | {"FASTMCP_UV_SPAWNED": "1"}
108
-
109
- logger.debug(f"Running command: {' '.join(cmd)}")
110
-
111
- try:
112
- # Run without capturing output so it flows through naturally
113
- process = subprocess.run(cmd, check=True, env=env)
114
- sys.exit(process.returncode)
115
- except subprocess.CalledProcessError as e:
116
- logger.error(f"Command failed: {e}")
117
- sys.exit(e.returncode)
118
-
119
- def _needs_setup(self) -> bool:
93
+ def _must_run_with_uv(self) -> bool:
120
94
  """Check if this environment config requires uv to set up.
121
95
 
122
96
  Returns:
@@ -132,15 +106,6 @@ class UVEnvironment(Environment):
132
106
  ]
133
107
  )
134
108
 
135
- # Backward compatibility aliases
136
- def needs_uv(self) -> bool:
137
- """Deprecated: Use _needs_setup() internally or check if build_command modifies the command."""
138
- return self._needs_setup()
139
-
140
- def build_uv_run_command(self, command: list[str]) -> list[str]:
141
- """Deprecated: Use build_command() instead."""
142
- return self.build_command(command)
143
-
144
109
  async def prepare(self, output_dir: Path | None = None) -> None:
145
110
  """Prepare the Python environment using uv.
146
111
 
@@ -157,7 +122,7 @@ class UVEnvironment(Environment):
157
122
  )
158
123
 
159
124
  # Only prepare environment if there are actual settings to apply
160
- if not self._needs_setup():
125
+ if not self._must_run_with_uv():
161
126
  logger.debug("No environment settings configured, skipping preparation")
162
127
  return
163
128
 
@@ -26,7 +26,7 @@ logger = get_logger("cli.config")
26
26
  FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json"
27
27
 
28
28
 
29
- # Type alias for source union (will expand with GitSource, etc in future)
29
+ # Type alias for source union (will expand with GitSource, etc. in future)
30
30
  SourceType: TypeAlias = FileSystemSource
31
31
 
32
32
  # Type alias for environment union (will expand with other environments in future)
@@ -36,7 +36,7 @@ EnvironmentType: TypeAlias = UVEnvironment
36
36
  class Deployment(BaseModel):
37
37
  """Configuration for server deployment and runtime settings."""
38
38
 
39
- transport: Literal["stdio", "http", "sse"] | None = Field(
39
+ transport: Literal["stdio", "http", "sse", "streamable-http"] | None = Field(
40
40
  default=None,
41
41
  description="Transport protocol to use",
42
42
  )
@@ -217,7 +217,7 @@ class MCPServerConfig(BaseModel):
217
217
  """
218
218
  if isinstance(v, dict):
219
219
  return Deployment(**v) # type: ignore[arg-type]
220
- return cast(Deployment, v)
220
+ return cast(Deployment, v) # type: ignore[return-value]
221
221
 
222
222
  @classmethod
223
223
  def from_file(cls, file_path: Path) -> MCPServerConfig:
@@ -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
@@ -403,7 +403,8 @@ class MCPServerConfig(BaseModel):
403
403
  run_args["port"] = self.deployment.port
404
404
  if self.deployment.path:
405
405
  run_args["path"] = self.deployment.path
406
- # Note: log_level not currently supported by run_async
406
+ if self.deployment.log_level:
407
+ run_args["log_level"] = self.deployment.log_level
407
408
 
408
409
  # Override with any provided kwargs
409
410
  run_args.update(kwargs)
@@ -9,7 +9,8 @@
9
9
  "enum": [
10
10
  "stdio",
11
11
  "http",
12
- "sse"
12
+ "sse",
13
+ "streamable-http"
13
14
  ],
14
15
  "type": "string"
15
16
  },
@@ -249,6 +250,7 @@
249
250
  "requirements": {
250
251
  "anyOf": [
251
252
  {
253
+ "format": "path",
252
254
  "type": "string"
253
255
  },
254
256
  {
@@ -266,6 +268,7 @@
266
268
  "project": {
267
269
  "anyOf": [
268
270
  {
271
+ "format": "path",
269
272
  "type": "string"
270
273
  },
271
274
  {
@@ -284,6 +287,7 @@
284
287
  "anyOf": [
285
288
  {
286
289
  "items": {
290
+ "format": "path",
287
291
  "type": "string"
288
292
  },
289
293
  "type": "array"
@@ -17,7 +17,6 @@ class Source(BaseModel, ABC):
17
17
  need preparation (e.g., local files), this is a no-op.
18
18
  """
19
19
  # Default implementation for sources that don't need preparation
20
- pass
21
20
 
22
21
  @abstractmethod
23
22
  async def load_server(self) -> Any:
@@ -175,16 +175,16 @@ class HTTPRoute(FastMCPBaseModel):
175
175
  # Export public symbols
176
176
  __all__ = [
177
177
  "HTTPRoute",
178
+ "HttpMethod",
179
+ "JsonSchema",
178
180
  "ParameterInfo",
181
+ "ParameterLocation",
179
182
  "RequestBodyInfo",
180
183
  "ResponseInfo",
181
- "HttpMethod",
182
- "ParameterLocation",
183
- "JsonSchema",
184
- "parse_openapi_to_http_routes",
184
+ "_handle_nullable_fields",
185
185
  "extract_output_schema_from_responses",
186
186
  "format_deep_object_parameter",
187
- "_handle_nullable_fields",
187
+ "parse_openapi_to_http_routes",
188
188
  ]
189
189
 
190
190
  # Type variables for generic parser
@@ -321,7 +321,7 @@ class OpenAPIParser(
321
321
  else:
322
322
  # Special handling for components
323
323
  if part == "components" and hasattr(target, "components"):
324
- target = getattr(target, "components")
324
+ target = target.components
325
325
  elif hasattr(target, part): # Fallback check
326
326
  target = getattr(target, part, None)
327
327
  else:
@@ -1178,10 +1178,10 @@ def _add_null_to_type(schema: dict[str, Any]) -> None:
1178
1178
  elif isinstance(current_type, list):
1179
1179
  # Add null to array if not already present
1180
1180
  if "null" not in current_type:
1181
- schema["type"] = current_type + ["null"]
1181
+ schema["type"] = [*current_type, "null"]
1182
1182
  elif "oneOf" in schema:
1183
1183
  # Convert oneOf to anyOf with null type
1184
- schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
1184
+ schema["anyOf"] = [*schema.pop("oneOf"), {"type": "null"}]
1185
1185
  elif "anyOf" in schema:
1186
1186
  # Add null type to anyOf if not already present
1187
1187
  if not any(item.get("type") == "null" for item in schema["anyOf"]):
@@ -1233,7 +1233,7 @@ def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | An
1233
1233
 
1234
1234
  # Handle properties nullable fields
1235
1235
  if has_property_nullable_field and "properties" in result:
1236
- for prop_name, prop_schema in result["properties"].items():
1236
+ for _prop_name, prop_schema in result["properties"].items():
1237
1237
  if isinstance(prop_schema, dict) and "nullable" in prop_schema:
1238
1238
  nullable_value = prop_schema.pop("nullable")
1239
1239
  if nullable_value and (
@@ -1371,7 +1371,7 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1371
1371
  if used_refs:
1372
1372
  result["$defs"] = {
1373
1373
  name: def_schema
1374
- for name, def_schema in result["$defs"].items()
1374
+ for name, def_schema in result["$defs"].items() # type: ignore[index]
1375
1375
  if name in used_refs
1376
1376
  }
1377
1377
  else:
@@ -1556,7 +1556,7 @@ def extract_output_schema_from_responses(
1556
1556
  if used_refs:
1557
1557
  output_schema["$defs"] = {
1558
1558
  name: def_schema
1559
- for name, def_schema in output_schema["$defs"].items()
1559
+ for name, def_schema in output_schema["$defs"].items() # type: ignore[index]
1560
1560
  if name in used_refs
1561
1561
  }
1562
1562
  else:
@@ -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, suppress
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
@@ -109,7 +110,7 @@ def run_server_in_process(
109
110
  proc.start()
110
111
 
111
112
  # Wait for server to be running
112
- max_attempts = 10
113
+ max_attempts = 30
113
114
  attempt = 0
114
115
  while attempt < max_attempts and proc.is_alive():
115
116
  try:
@@ -117,10 +118,12 @@ def run_server_in_process(
117
118
  s.connect((host, port))
118
119
  break
119
120
  except ConnectionRefusedError:
120
- if attempt < 3:
121
- time.sleep(0.01)
122
- else:
121
+ if attempt < 5:
122
+ time.sleep(0.05)
123
+ elif attempt < 15:
123
124
  time.sleep(0.1)
125
+ else:
126
+ time.sleep(0.2)
124
127
  attempt += 1
125
128
  else:
126
129
  raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
@@ -137,14 +140,94 @@ def run_server_in_process(
137
140
  raise RuntimeError("Server process failed to terminate even after kill")
138
141
 
139
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
+ with suppress(asyncio.CancelledError):
220
+ await server_task
221
+
222
+
140
223
  @contextmanager
141
224
  def caplog_for_fastmcp(caplog):
142
225
  """Context manager to capture logs from FastMCP loggers even when propagation is disabled."""
143
226
  caplog.clear()
144
- logger = logging.getLogger("FastMCP")
227
+ logger = logging.getLogger("fastmcp")
145
228
  logger.addHandler(caplog.handler)
146
229
  try:
147
- yield
230
+ yield caplog
148
231
  finally:
149
232
  logger.removeHandler(caplog.handler)
150
233
 
@@ -31,6 +31,10 @@ NotSet = ...
31
31
  NotSetT: TypeAlias = EllipsisType
32
32
 
33
33
 
34
+ def get_fn_name(fn: Callable[..., Any]) -> str:
35
+ return fn.__name__ # ty: ignore[unresolved-attribute]
36
+
37
+
34
38
  class FastMCPBaseModel(BaseModel):
35
39
  """Base model for FastMCP models."""
36
40
 
@@ -80,11 +84,11 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
80
84
  # Handle both functions and methods
81
85
  if inspect.ismethod(cls):
82
86
  actual_func = cls.__func__
83
- code = actual_func.__code__
84
- globals_dict = actual_func.__globals__
85
- name = actual_func.__name__
86
- defaults = actual_func.__defaults__
87
- closure = actual_func.__closure__
87
+ code = actual_func.__code__ # ty: ignore[unresolved-attribute]
88
+ globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
89
+ name = actual_func.__name__ # ty: ignore[unresolved-attribute]
90
+ defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
91
+ closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
88
92
  else:
89
93
  code = cls.__code__
90
94
  globals_dict = cls.__globals__
@@ -171,6 +175,55 @@ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
171
175
  return None
172
176
 
173
177
 
178
+ def create_function_without_params(
179
+ fn: Callable[..., Any], exclude_params: list[str]
180
+ ) -> Callable[..., Any]:
181
+ """
182
+ Create a new function with the same code but without the specified parameters in annotations.
183
+
184
+ This is used to exclude parameters from type adapter processing when they can't be serialized.
185
+ The excluded parameters are removed from the function's __annotations__ dictionary.
186
+ """
187
+ import types
188
+
189
+ if inspect.ismethod(fn):
190
+ actual_func = fn.__func__
191
+ code = actual_func.__code__ # ty: ignore[unresolved-attribute]
192
+ globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
193
+ name = actual_func.__name__ # ty: ignore[unresolved-attribute]
194
+ defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
195
+ closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
196
+ else:
197
+ code = fn.__code__ # ty: ignore[unresolved-attribute]
198
+ globals_dict = fn.__globals__ # ty: ignore[unresolved-attribute]
199
+ name = fn.__name__ # ty: ignore[unresolved-attribute]
200
+ defaults = fn.__defaults__ # ty: ignore[unresolved-attribute]
201
+ closure = fn.__closure__ # ty: ignore[unresolved-attribute]
202
+
203
+ # Create a copy of annotations without the excluded parameters
204
+ original_annotations = getattr(fn, "__annotations__", {})
205
+ new_annotations = {
206
+ k: v for k, v in original_annotations.items() if k not in exclude_params
207
+ }
208
+
209
+ new_func = types.FunctionType(
210
+ code,
211
+ globals_dict,
212
+ name,
213
+ defaults,
214
+ closure,
215
+ )
216
+ new_func.__dict__.update(fn.__dict__)
217
+ new_func.__module__ = fn.__module__
218
+ new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute]
219
+ new_func.__annotations__ = new_annotations
220
+
221
+ if inspect.ismethod(fn):
222
+ return types.MethodType(new_func, fn.__self__)
223
+ else:
224
+ return new_func
225
+
226
+
174
227
  class Image:
175
228
  """Helper class for returning images from tools."""
176
229
 
@@ -186,34 +239,33 @@ class Image:
186
239
  if path is not None and data is not None:
187
240
  raise ValueError("Only one of path or data can be provided")
188
241
 
189
- self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
242
+ self.path = self._get_expanded_path(path)
190
243
  self.data = data
191
244
  self._format = format
192
245
  self._mime_type = self._get_mime_type()
193
246
  self.annotations = annotations
194
247
 
248
+ @staticmethod
249
+ def _get_expanded_path(path: str | Path | None) -> Path | None:
250
+ """Expand environment variables and user home in path."""
251
+ return Path(os.path.expandvars(str(path))).expanduser() if path else None
252
+
195
253
  def _get_mime_type(self) -> str:
196
254
  """Get MIME type from format or guess from file extension."""
197
255
  if self._format:
198
256
  return f"image/{self._format.lower()}"
199
257
 
200
258
  if self.path:
201
- suffix = self.path.suffix.lower()
202
- return {
203
- ".png": "image/png",
204
- ".jpg": "image/jpeg",
205
- ".jpeg": "image/jpeg",
206
- ".gif": "image/gif",
207
- ".webp": "image/webp",
208
- }.get(suffix, "application/octet-stream")
259
+ # Workaround for WEBP in Py3.10
260
+ mimetypes.add_type("image/webp", ".webp")
261
+ resp = mimetypes.guess_type(self.path, strict=False)
262
+ if resp and resp[0] is not None:
263
+ return resp[0]
264
+ return "application/octet-stream"
209
265
  return "image/png" # default for raw binary data
210
266
 
211
- def to_image_content(
212
- self,
213
- mime_type: str | None = None,
214
- annotations: Annotations | None = None,
215
- ) -> mcp.types.ImageContent:
216
- """Convert to MCP ImageContent."""
267
+ def _get_data(self) -> str:
268
+ """Get raw image data as base64-encoded string."""
217
269
  if self.path:
218
270
  with open(self.path, "rb") as f:
219
271
  data = base64.b64encode(f.read()).decode()
@@ -221,6 +273,15 @@ class Image:
221
273
  data = base64.b64encode(self.data).decode()
222
274
  else:
223
275
  raise ValueError("No image data available")
276
+ return data
277
+
278
+ def to_image_content(
279
+ self,
280
+ mime_type: str | None = None,
281
+ annotations: Annotations | None = None,
282
+ ) -> mcp.types.ImageContent:
283
+ """Convert to MCP ImageContent."""
284
+ data = self._get_data()
224
285
 
225
286
  return mcp.types.ImageContent(
226
287
  type="image",
@@ -229,6 +290,11 @@ class Image:
229
290
  annotations=annotations or self.annotations,
230
291
  )
231
292
 
293
+ def to_data_uri(self, mime_type: str | None = None) -> str:
294
+ """Get image as a data URI."""
295
+ data = self._get_data()
296
+ return f"data:{mime_type or self._mime_type};base64,{data}"
297
+
232
298
 
233
299
  class Audio:
234
300
  """Helper class for returning audio from tools."""
@@ -289,7 +355,7 @@ class Audio:
289
355
 
290
356
 
291
357
  class File:
292
- """Helper class for returning audio from tools."""
358
+ """Helper class for returning file data from tools."""
293
359
 
294
360
  def __init__(
295
361
  self,