fastmcp 2.9.2__py3-none-any.whl → 2.10.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 (42) hide show
  1. fastmcp/cli/cli.py +16 -1
  2. fastmcp/cli/run.py +4 -0
  3. fastmcp/client/auth/oauth.py +5 -82
  4. fastmcp/client/client.py +114 -24
  5. fastmcp/client/elicitation.py +63 -0
  6. fastmcp/client/transports.py +50 -36
  7. fastmcp/contrib/component_manager/README.md +170 -0
  8. fastmcp/contrib/component_manager/__init__.py +4 -0
  9. fastmcp/contrib/component_manager/component_manager.py +186 -0
  10. fastmcp/contrib/component_manager/component_service.py +225 -0
  11. fastmcp/contrib/component_manager/example.py +59 -0
  12. fastmcp/prompts/prompt.py +12 -4
  13. fastmcp/resources/resource.py +8 -3
  14. fastmcp/resources/template.py +5 -0
  15. fastmcp/server/auth/auth.py +15 -0
  16. fastmcp/server/auth/providers/bearer.py +41 -3
  17. fastmcp/server/auth/providers/bearer_env.py +4 -0
  18. fastmcp/server/auth/providers/in_memory.py +15 -0
  19. fastmcp/server/context.py +144 -4
  20. fastmcp/server/elicitation.py +160 -0
  21. fastmcp/server/http.py +1 -9
  22. fastmcp/server/low_level.py +4 -2
  23. fastmcp/server/middleware/__init__.py +14 -1
  24. fastmcp/server/middleware/logging.py +11 -0
  25. fastmcp/server/middleware/middleware.py +10 -6
  26. fastmcp/server/openapi.py +19 -77
  27. fastmcp/server/proxy.py +13 -6
  28. fastmcp/server/server.py +76 -11
  29. fastmcp/settings.py +0 -17
  30. fastmcp/tools/tool.py +209 -57
  31. fastmcp/tools/tool_manager.py +2 -3
  32. fastmcp/tools/tool_transform.py +125 -26
  33. fastmcp/utilities/cli.py +106 -0
  34. fastmcp/utilities/components.py +5 -1
  35. fastmcp/utilities/json_schema_type.py +648 -0
  36. fastmcp/utilities/openapi.py +69 -0
  37. fastmcp/utilities/types.py +50 -19
  38. {fastmcp-2.9.2.dist-info → fastmcp-2.10.0.dist-info}/METADATA +3 -2
  39. {fastmcp-2.9.2.dist-info → fastmcp-2.10.0.dist-info}/RECORD +42 -33
  40. {fastmcp-2.9.2.dist-info → fastmcp-2.10.0.dist-info}/WHEEL +0 -0
  41. {fastmcp-2.9.2.dist-info → fastmcp-2.10.0.dist-info}/entry_points.txt +0 -0
  42. {fastmcp-2.9.2.dist-info → fastmcp-2.10.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/tools/tool.py CHANGED
@@ -1,17 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- import json
5
4
  from collections.abc import Callable
6
5
  from dataclasses import dataclass
7
- from typing import TYPE_CHECKING, Any
6
+ from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar
8
7
 
8
+ import mcp.types
9
9
  import pydantic_core
10
- from mcp.types import TextContent, ToolAnnotations
10
+ from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
11
  from mcp.types import Tool as MCPTool
12
- from pydantic import Field
12
+ from pydantic import Field, PydanticSchemaGenerationError
13
13
 
14
- import fastmcp
15
14
  from fastmcp.server.dependencies import get_context
16
15
  from fastmcp.utilities.components import FastMCPComponent
17
16
  from fastmcp.utilities.json_schema import compress_schema
@@ -20,9 +19,11 @@ from fastmcp.utilities.types import (
20
19
  Audio,
21
20
  File,
22
21
  Image,
23
- MCPContent,
22
+ NotSet,
23
+ NotSetT,
24
24
  find_kwarg_by_type,
25
25
  get_cached_typeadapter,
26
+ replace_type,
26
27
  )
27
28
 
28
29
  if TYPE_CHECKING:
@@ -30,21 +31,80 @@ if TYPE_CHECKING:
30
31
 
31
32
  logger = get_logger(__name__)
32
33
 
34
+ T = TypeVar("T")
35
+
36
+
37
+ @dataclass
38
+ class _WrappedResult(Generic[T]):
39
+ """Generic wrapper for non-object return types."""
40
+
41
+ result: T
42
+
43
+
44
+ class _UnserializableType:
45
+ pass
46
+
33
47
 
34
48
  def default_serializer(data: Any) -> str:
35
49
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
36
50
 
37
51
 
52
+ class ToolResult:
53
+ def __init__(
54
+ self,
55
+ content: list[ContentBlock] | Any | None = None,
56
+ structured_content: dict[str, Any] | Any | None = None,
57
+ ):
58
+ if content is None and structured_content is None:
59
+ raise ValueError("Either content or structured_content must be provided")
60
+ elif content is None:
61
+ content = structured_content
62
+
63
+ self.content = _convert_to_content(content)
64
+
65
+ if structured_content is not None:
66
+ try:
67
+ structured_content = pydantic_core.to_jsonable_python(
68
+ structured_content
69
+ )
70
+ except pydantic_core.PydanticSerializationError as e:
71
+ logger.error(
72
+ f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
73
+ )
74
+ raise
75
+ if not isinstance(structured_content, dict):
76
+ raise ValueError(
77
+ "structured_content must be a dict or None. "
78
+ f"Got {type(structured_content).__name__}: {structured_content!r}. "
79
+ "Tools should wrap non-dict values based on their output_schema."
80
+ )
81
+ self.structured_content: dict[str, Any] | None = structured_content
82
+
83
+ def to_mcp_result(
84
+ self,
85
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
86
+ if self.structured_content is None:
87
+ return self.content
88
+ return self.content, self.structured_content
89
+
90
+
38
91
  class Tool(FastMCPComponent):
39
92
  """Internal tool registration info."""
40
93
 
41
- parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
42
- annotations: ToolAnnotations | None = Field(
43
- default=None, description="Additional annotations about the tool"
44
- )
45
- serializer: Callable[[Any], str] | None = Field(
46
- default=None, description="Optional custom serializer for tool results"
47
- )
94
+ parameters: Annotated[
95
+ dict[str, Any], Field(description="JSON schema for tool parameters")
96
+ ]
97
+ output_schema: Annotated[
98
+ dict[str, Any] | None, Field(description="JSON schema for tool output")
99
+ ] = None
100
+ annotations: Annotated[
101
+ ToolAnnotations | None,
102
+ Field(description="Additional annotations about the tool"),
103
+ ] = None
104
+ serializer: Annotated[
105
+ Callable[[Any], str] | None,
106
+ Field(description="Optional custom serializer for tool results"),
107
+ ] = None
48
108
 
49
109
  def enable(self) -> None:
50
110
  super().enable()
@@ -63,11 +123,20 @@ class Tool(FastMCPComponent):
63
123
  pass # No context available
64
124
 
65
125
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
126
+ if self.title:
127
+ title = self.title
128
+ elif self.annotations and self.annotations.title:
129
+ title = self.annotations.title
130
+ else:
131
+ title = None
132
+
66
133
  kwargs = {
67
134
  "name": self.name,
68
135
  "description": self.description,
69
136
  "inputSchema": self.parameters,
137
+ "outputSchema": self.output_schema,
70
138
  "annotations": self.annotations,
139
+ "title": title,
71
140
  }
72
141
  return MCPTool(**kwargs | overrides)
73
142
 
@@ -75,10 +144,12 @@ class Tool(FastMCPComponent):
75
144
  def from_function(
76
145
  fn: Callable[..., Any],
77
146
  name: str | None = None,
147
+ title: str | None = None,
78
148
  description: str | None = None,
79
149
  tags: set[str] | None = None,
80
150
  annotations: ToolAnnotations | None = None,
81
151
  exclude_args: list[str] | None = None,
152
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
82
153
  serializer: Callable[[Any], str] | None = None,
83
154
  enabled: bool | None = None,
84
155
  ) -> FunctionTool:
@@ -86,16 +157,26 @@ class Tool(FastMCPComponent):
86
157
  return FunctionTool.from_function(
87
158
  fn=fn,
88
159
  name=name,
160
+ title=title,
89
161
  description=description,
90
162
  tags=tags,
91
163
  annotations=annotations,
92
164
  exclude_args=exclude_args,
165
+ output_schema=output_schema,
93
166
  serializer=serializer,
94
167
  enabled=enabled,
95
168
  )
96
169
 
97
- async def run(self, arguments: dict[str, Any]) -> list[MCPContent]:
98
- """Run the tool with arguments."""
170
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
171
+ """
172
+ Run the tool with arguments.
173
+
174
+ This method is not implemented in the base Tool class and must be
175
+ implemented by subclasses.
176
+
177
+ `run()` can EITHER return a list of ContentBlocks, or a tuple of
178
+ (list of ContentBlocks, dict of structured output).
179
+ """
99
180
  raise NotImplementedError("Subclasses must implement run()")
100
181
 
101
182
  @classmethod
@@ -108,6 +189,7 @@ class Tool(FastMCPComponent):
108
189
  description: str | None = None,
109
190
  tags: set[str] | None = None,
110
191
  annotations: ToolAnnotations | None = None,
192
+ output_schema: dict[str, Any] | None | Literal[False] = None,
111
193
  serializer: Callable[[Any], str] | None = None,
112
194
  enabled: bool | None = None,
113
195
  ) -> TransformedTool:
@@ -121,6 +203,7 @@ class Tool(FastMCPComponent):
121
203
  description=description,
122
204
  tags=tags,
123
205
  annotations=annotations,
206
+ output_schema=output_schema,
124
207
  serializer=serializer,
125
208
  enabled=enabled,
126
209
  )
@@ -134,10 +217,12 @@ class FunctionTool(Tool):
134
217
  cls,
135
218
  fn: Callable[..., Any],
136
219
  name: str | None = None,
220
+ title: str | None = None,
137
221
  description: str | None = None,
138
222
  tags: set[str] | None = None,
139
223
  annotations: ToolAnnotations | None = None,
140
224
  exclude_args: list[str] | None = None,
225
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
141
226
  serializer: Callable[[Any], str] | None = None,
142
227
  enabled: bool | None = None,
143
228
  ) -> FunctionTool:
@@ -148,18 +233,33 @@ class FunctionTool(Tool):
148
233
  if name is None and parsed_fn.name == "<lambda>":
149
234
  raise ValueError("You must provide a name for lambda functions")
150
235
 
236
+ if isinstance(output_schema, NotSetT):
237
+ output_schema = parsed_fn.output_schema
238
+ elif output_schema is False:
239
+ output_schema = None
240
+ # Note: explicit schemas (dict) are used as-is without auto-wrapping
241
+
242
+ # Validate that explicit schemas are object type for structured content
243
+ if output_schema is not None and isinstance(output_schema, dict):
244
+ if output_schema.get("type") != "object":
245
+ raise ValueError(
246
+ f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {output_schema!r}'
247
+ )
248
+
151
249
  return cls(
152
250
  fn=parsed_fn.fn,
153
251
  name=name or parsed_fn.name,
252
+ title=title,
154
253
  description=description or parsed_fn.description,
155
- parameters=parsed_fn.parameters,
156
- tags=tags or set(),
254
+ parameters=parsed_fn.input_schema,
255
+ output_schema=output_schema,
157
256
  annotations=annotations,
257
+ tags=tags or set(),
158
258
  serializer=serializer,
159
259
  enabled=enabled if enabled is not None else True,
160
260
  )
161
261
 
162
- async def run(self, arguments: dict[str, Any]) -> list[MCPContent]:
262
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
163
263
  """Run the tool with arguments."""
164
264
  from fastmcp.server.context import Context
165
265
 
@@ -169,41 +269,39 @@ class FunctionTool(Tool):
169
269
  if context_kwarg and context_kwarg not in arguments:
170
270
  arguments[context_kwarg] = get_context()
171
271
 
172
- if fastmcp.settings.tool_attempt_parse_json_args:
173
- # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
174
- # being passed in as JSON inside a string rather than an actual list.
175
- #
176
- # Claude desktop is prone to this - in fact it seems incapable of NOT doing
177
- # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
178
- # which can be pre-parsed here.
179
- signature = inspect.signature(self.fn)
180
- for param_name in self.parameters["properties"]:
181
- arg = arguments.get(param_name, None)
182
- # if not in signature, we won't have annotations, so skip logic
183
- if param_name not in signature.parameters:
184
- continue
185
- # if not a string, we won't have a JSON to parse, so skip logic
186
- if not isinstance(arg, str):
187
- continue
188
- # skip if the type is a simple type (int, float, bool)
189
- if signature.parameters[param_name].annotation in (
190
- int,
191
- float,
192
- bool,
193
- ):
194
- continue
195
- try:
196
- arguments[param_name] = json.loads(arg)
197
-
198
- except json.JSONDecodeError:
199
- pass
200
-
201
272
  type_adapter = get_cached_typeadapter(self.fn)
202
273
  result = type_adapter.validate_python(arguments)
274
+
203
275
  if inspect.isawaitable(result):
204
276
  result = await result
205
277
 
206
- return _convert_to_content(result, serializer=self.serializer)
278
+ if isinstance(result, ToolResult):
279
+ return result
280
+
281
+ unstructured_result = _convert_to_content(result, serializer=self.serializer)
282
+
283
+ structured_output = None
284
+ # First handle structured content based on output schema, if any
285
+ if self.output_schema is not None:
286
+ if self.output_schema.get("x-fastmcp-wrap-result"):
287
+ # Schema says wrap - always wrap in result key
288
+ structured_output = {"result": result}
289
+ else:
290
+ structured_output = result
291
+ # If no output schema, try to serialize the result. If it is a dict, use
292
+ # it as structured content. If it is not a dict, ignore it.
293
+ if structured_output is None:
294
+ try:
295
+ structured_output = pydantic_core.to_jsonable_python(result)
296
+ if not isinstance(structured_output, dict):
297
+ structured_output = None
298
+ except Exception:
299
+ pass
300
+
301
+ return ToolResult(
302
+ content=unstructured_result,
303
+ structured_content=structured_output,
304
+ )
207
305
 
208
306
 
209
307
  @dataclass
@@ -211,7 +309,8 @@ class ParsedFunction:
211
309
  fn: Callable[..., Any]
212
310
  name: str
213
311
  description: str | None
214
- parameters: dict[str, Any]
312
+ input_schema: dict[str, Any]
313
+ output_schema: dict[str, Any] | None
215
314
 
216
315
  @classmethod
217
316
  def from_function(
@@ -219,6 +318,7 @@ class ParsedFunction:
219
318
  fn: Callable[..., Any],
220
319
  exclude_args: list[str] | None = None,
221
320
  validate: bool = True,
321
+ wrap_non_object_output_schema: bool = True,
222
322
  ) -> ParsedFunction:
223
323
  from fastmcp.server.context import Context
224
324
 
@@ -257,9 +357,6 @@ class ParsedFunction:
257
357
  if isinstance(fn, staticmethod):
258
358
  fn = fn.__func__
259
359
 
260
- type_adapter = get_cached_typeadapter(fn)
261
- schema = type_adapter.json_schema()
262
-
263
360
  prune_params: list[str] = []
264
361
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
265
362
  if context_kwarg:
@@ -267,12 +364,67 @@ class ParsedFunction:
267
364
  if exclude_args:
268
365
  prune_params.extend(exclude_args)
269
366
 
270
- schema = compress_schema(schema, prune_params=prune_params)
367
+ input_type_adapter = get_cached_typeadapter(fn)
368
+ input_schema = input_type_adapter.json_schema()
369
+ input_schema = compress_schema(input_schema, prune_params=prune_params)
370
+
371
+ output_schema = None
372
+ output_type = inspect.signature(fn).return_annotation
373
+
374
+ if output_type not in (inspect._empty, None, Any, ...):
375
+ # there are a variety of types that we don't want to attempt to
376
+ # serialize because they are either used by FastMCP internally,
377
+ # or are MCP content types that explicitly don't form structured
378
+ # content. By replacing them with an explicitly unserializable type,
379
+ # we ensure that no output schema is automatically generated.
380
+ clean_output_type = replace_type(
381
+ output_type,
382
+ {
383
+ t: _UnserializableType
384
+ for t in (
385
+ Image,
386
+ Audio,
387
+ File,
388
+ ToolResult,
389
+ mcp.types.TextContent,
390
+ mcp.types.ImageContent,
391
+ mcp.types.AudioContent,
392
+ mcp.types.ResourceLink,
393
+ mcp.types.EmbeddedResource,
394
+ )
395
+ },
396
+ )
397
+
398
+ try:
399
+ type_adapter = get_cached_typeadapter(clean_output_type)
400
+ base_schema = type_adapter.json_schema()
401
+
402
+ # Generate schema for wrapped type if it's non-object
403
+ # because MCP requires that output schemas are objects
404
+ if (
405
+ wrap_non_object_output_schema
406
+ and base_schema.get("type") != "object"
407
+ ):
408
+ # Use the wrapped result schema directly
409
+ wrapped_type = _WrappedResult[clean_output_type]
410
+ wrapped_adapter = get_cached_typeadapter(wrapped_type)
411
+ output_schema = wrapped_adapter.json_schema()
412
+ output_schema["x-fastmcp-wrap-result"] = True
413
+ else:
414
+ output_schema = base_schema
415
+
416
+ output_schema = compress_schema(output_schema)
417
+
418
+ except PydanticSchemaGenerationError as e:
419
+ if "_UnserializableType" not in str(e):
420
+ logger.debug(f"Unable to generate schema for type {output_type!r}")
421
+
271
422
  return cls(
272
423
  fn=fn,
273
424
  name=fn_name,
274
425
  description=fn_doc,
275
- parameters=schema,
426
+ input_schema=input_schema,
427
+ output_schema=output_schema or None,
276
428
  )
277
429
 
278
430
 
@@ -280,12 +432,12 @@ def _convert_to_content(
280
432
  result: Any,
281
433
  serializer: Callable[[Any], str] | None = None,
282
434
  _process_as_single_item: bool = False,
283
- ) -> list[MCPContent]:
435
+ ) -> list[ContentBlock]:
284
436
  """Convert a result to a sequence of content objects."""
285
437
  if result is None:
286
438
  return []
287
439
 
288
- if isinstance(result, MCPContent):
440
+ if isinstance(result, ContentBlock):
289
441
  return [result]
290
442
 
291
443
  if isinstance(result, Image):
@@ -308,7 +460,7 @@ def _convert_to_content(
308
460
  other_content = []
309
461
 
310
462
  for item in result:
311
- if isinstance(item, MCPContent | Image | Audio | File):
463
+ if isinstance(item, ContentBlock | Image | Audio | File):
312
464
  mcp_types.append(_convert_to_content(item)[0])
313
465
  else:
314
466
  other_content.append(item)
@@ -9,9 +9,8 @@ from mcp.types import ToolAnnotations
9
9
  from fastmcp import settings
10
10
  from fastmcp.exceptions import NotFoundError, ToolError
11
11
  from fastmcp.settings import DuplicateBehavior
12
- from fastmcp.tools.tool import Tool
12
+ from fastmcp.tools.tool import Tool, ToolResult
13
13
  from fastmcp.utilities.logging import get_logger
14
- from fastmcp.utilities.types import MCPContent
15
14
 
16
15
  if TYPE_CHECKING:
17
16
  from fastmcp.server.server import MountedServer
@@ -170,7 +169,7 @@ class ToolManager:
170
169
  else:
171
170
  raise NotFoundError(f"Tool {key!r} not found")
172
171
 
173
- async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
172
+ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
174
173
  """
175
174
  Internal API for servers: Finds and calls a tool, respecting the
176
175
  filtered protocol path.