Jeremiah Lowin commited on
Commit
11ebe03
·
1 Parent(s): 47f188e

Add output schema to tools

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -4,12 +4,13 @@ import inspect
4
  import json
5
  from collections.abc import Callable
6
  from dataclasses import dataclass
7
- from typing import TYPE_CHECKING, Any
8
 
 
9
  import pydantic_core
10
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
  from mcp.types import Tool as MCPTool
12
- from pydantic import Field
13
 
14
  import fastmcp
15
  from fastmcp.server.dependencies import get_context
@@ -20,8 +21,11 @@ from fastmcp.utilities.types import (
20
  Audio,
21
  File,
22
  Image,
 
 
23
  find_kwarg_by_type,
24
  get_cached_typeadapter,
 
25
  )
26
 
27
  if TYPE_CHECKING:
@@ -37,19 +41,27 @@ def default_serializer(data: Any) -> str:
37
  class Tool(FastMCPComponent):
38
  """Internal tool registration info."""
39
 
40
- parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
41
- annotations: ToolAnnotations | None = Field(
42
- default=None, description="Additional annotations about the tool"
43
- )
44
- serializer: Callable[[Any], str] | None = Field(
45
- default=None, description="Optional custom serializer for tool results"
46
- )
 
 
 
 
 
 
 
47
 
48
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
49
  kwargs = {
50
  "name": self.name,
51
  "description": self.description,
52
  "inputSchema": self.parameters,
 
53
  "annotations": self.annotations,
54
  }
55
  return MCPTool(**kwargs | overrides)
@@ -62,6 +74,7 @@ class Tool(FastMCPComponent):
62
  tags: set[str] | None = None,
63
  annotations: ToolAnnotations | None = None,
64
  exclude_args: list[str] | None = None,
 
65
  serializer: Callable[[Any], str] | None = None,
66
  enabled: bool | None = None,
67
  ) -> FunctionTool:
@@ -73,6 +86,7 @@ class Tool(FastMCPComponent):
73
  tags=tags,
74
  annotations=annotations,
75
  exclude_args=exclude_args,
 
76
  serializer=serializer,
77
  enabled=enabled,
78
  )
@@ -121,6 +135,7 @@ class FunctionTool(Tool):
121
  tags: set[str] | None = None,
122
  annotations: ToolAnnotations | None = None,
123
  exclude_args: list[str] | None = None,
 
124
  serializer: Callable[[Any], str] | None = None,
125
  enabled: bool | None = None,
126
  ) -> FunctionTool:
@@ -131,13 +146,17 @@ class FunctionTool(Tool):
131
  if name is None and parsed_fn.name == "<lambda>":
132
  raise ValueError("You must provide a name for lambda functions")
133
 
 
 
 
134
  return cls(
135
  fn=parsed_fn.fn,
136
  name=name or parsed_fn.name,
137
  description=description or parsed_fn.description,
138
- parameters=parsed_fn.parameters,
139
- tags=tags or set(),
140
  annotations=annotations,
 
141
  serializer=serializer,
142
  enabled=enabled if enabled is not None else True,
143
  )
@@ -194,7 +213,8 @@ class ParsedFunction:
194
  fn: Callable[..., Any]
195
  name: str
196
  description: str | None
197
- parameters: dict[str, Any]
 
198
 
199
  @classmethod
200
  def from_function(
@@ -240,9 +260,6 @@ class ParsedFunction:
240
  if isinstance(fn, staticmethod):
241
  fn = fn.__func__
242
 
243
- type_adapter = get_cached_typeadapter(fn)
244
- schema = type_adapter.json_schema()
245
-
246
  prune_params: list[str] = []
247
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
248
  if context_kwarg:
@@ -250,12 +267,33 @@ class ParsedFunction:
250
  if exclude_args:
251
  prune_params.extend(exclude_args)
252
 
253
- schema = compress_schema(schema, prune_params=prune_params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  return cls(
255
  fn=fn,
256
  name=fn_name,
257
  description=fn_doc,
258
- parameters=schema,
 
259
  )
260
 
261
 
 
4
  import json
5
  from collections.abc import Callable
6
  from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Annotated, Any
8
 
9
+ import mcp.types
10
  import pydantic_core
11
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
12
  from mcp.types import Tool as MCPTool
13
+ from pydantic import Field, PydanticSchemaGenerationError
14
 
15
  import fastmcp
16
  from fastmcp.server.dependencies import get_context
 
21
  Audio,
22
  File,
23
  Image,
24
+ NotSet,
25
+ NotSetT,
26
  find_kwarg_by_type,
27
  get_cached_typeadapter,
28
+ replace_type,
29
  )
30
 
31
  if TYPE_CHECKING:
 
41
  class Tool(FastMCPComponent):
42
  """Internal tool registration info."""
43
 
44
+ parameters: Annotated[
45
+ dict[str, Any], Field(description="JSON schema for tool parameters")
46
+ ]
47
+ output_schema: Annotated[
48
+ dict[str, Any] | None, Field(description="JSON schema for tool output")
49
+ ] = None
50
+ annotations: Annotated[
51
+ ToolAnnotations | None,
52
+ Field(description="Additional annotations about the tool"),
53
+ ] = None
54
+ serializer: Annotated[
55
+ Callable[[Any], str] | None,
56
+ Field(description="Optional custom serializer for tool results"),
57
+ ] = None
58
 
59
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
60
  kwargs = {
61
  "name": self.name,
62
  "description": self.description,
63
  "inputSchema": self.parameters,
64
+ "outputSchema": self.output_schema,
65
  "annotations": self.annotations,
66
  }
67
  return MCPTool(**kwargs | overrides)
 
74
  tags: set[str] | None = None,
75
  annotations: ToolAnnotations | None = None,
76
  exclude_args: list[str] | None = None,
77
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
78
  serializer: Callable[[Any], str] | None = None,
79
  enabled: bool | None = None,
80
  ) -> FunctionTool:
 
86
  tags=tags,
87
  annotations=annotations,
88
  exclude_args=exclude_args,
89
+ output_schema=output_schema,
90
  serializer=serializer,
91
  enabled=enabled,
92
  )
 
135
  tags: set[str] | None = None,
136
  annotations: ToolAnnotations | None = None,
137
  exclude_args: list[str] | None = None,
138
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
139
  serializer: Callable[[Any], str] | None = None,
140
  enabled: bool | None = None,
141
  ) -> FunctionTool:
 
146
  if name is None and parsed_fn.name == "<lambda>":
147
  raise ValueError("You must provide a name for lambda functions")
148
 
149
+ if isinstance(output_schema, NotSetT):
150
+ output_schema = parsed_fn.output_schema
151
+
152
  return cls(
153
  fn=parsed_fn.fn,
154
  name=name or parsed_fn.name,
155
  description=description or parsed_fn.description,
156
+ parameters=parsed_fn.input_schema,
157
+ output_schema=output_schema,
158
  annotations=annotations,
159
+ tags=tags or set(),
160
  serializer=serializer,
161
  enabled=enabled if enabled is not None else True,
162
  )
 
213
  fn: Callable[..., Any]
214
  name: str
215
  description: str | None
216
+ input_schema: dict[str, Any]
217
+ output_schema: dict[str, Any] | None
218
 
219
  @classmethod
220
  def from_function(
 
260
  if isinstance(fn, staticmethod):
261
  fn = fn.__func__
262
 
 
 
 
263
  prune_params: list[str] = []
264
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
265
  if context_kwarg:
 
267
  if exclude_args:
268
  prune_params.extend(exclude_args)
269
 
270
+ input_type_adapter = get_cached_typeadapter(fn)
271
+ input_schema = input_type_adapter.json_schema()
272
+ input_schema = compress_schema(input_schema, prune_params=prune_params)
273
+
274
+ output_schema = None
275
+ output_type = inspect.signature(fn).return_annotation
276
+ if output_type is not inspect._empty:
277
+ try:
278
+ replaced_output_type = replace_type(
279
+ output_type,
280
+ {
281
+ Image: mcp.types.ImageContent,
282
+ Audio: mcp.types.AudioContent,
283
+ File: mcp.types.EmbeddedResource,
284
+ },
285
+ )
286
+ output_type_adapter = get_cached_typeadapter(replaced_output_type)
287
+ output_schema = output_type_adapter.json_schema()
288
+ except PydanticSchemaGenerationError:
289
+ logger.debug(f"Unable to generate schema for type {output_type!r}")
290
+
291
  return cls(
292
  fn=fn,
293
  name=fn_name,
294
  description=fn_doc,
295
+ input_schema=input_schema,
296
+ output_schema=output_schema,
297
  )
298
 
299
 
src/fastmcp/tools/tool_transform.py CHANGED
@@ -4,7 +4,6 @@ import inspect
4
  from collections.abc import Callable
5
  from contextvars import ContextVar
6
  from dataclasses import dataclass
7
- from types import EllipsisType
8
  from typing import Any, Literal
9
 
10
  from mcp.types import ContentBlock, ToolAnnotations
@@ -12,12 +11,10 @@ from pydantic import ConfigDict
12
 
13
  from fastmcp.tools.tool import ParsedFunction, Tool
14
  from fastmcp.utilities.logging import get_logger
15
- from fastmcp.utilities.types import get_cached_typeadapter
16
 
17
  logger = get_logger(__name__)
18
 
19
- NotSet = ...
20
-
21
 
22
  # Context variable to store current transformed tool
23
  _current_tool: ContextVar[TransformedTool | None] = ContextVar(
@@ -131,14 +128,14 @@ class ArgTransform:
131
  ArgTransform(name="new_name", description="New desc", default=None, type=int)
132
  """
133
 
134
- name: str | EllipsisType = NotSet
135
- description: str | EllipsisType = NotSet
136
- default: Any | EllipsisType = NotSet
137
- default_factory: Callable[[], Any] | EllipsisType = NotSet
138
- type: Any | EllipsisType = NotSet
139
  hide: bool = False
140
- required: Literal[True] | EllipsisType = NotSet
141
- examples: Any | EllipsisType = NotSet
142
 
143
  def __post_init__(self):
144
  """Validate that only one of default or default_factory is provided."""
@@ -334,7 +331,7 @@ class TransformedTool(Tool):
334
  has_kwargs = cls._function_has_kwargs(transform_fn)
335
 
336
  # Validate function parameters against transformed schema
337
- fn_params = set(parsed_fn.parameters.get("properties", {}).keys())
338
  transformed_params = set(schema.get("properties", {}).keys())
339
 
340
  if not has_kwargs:
@@ -351,7 +348,7 @@ class TransformedTool(Tool):
351
  # ArgTransform takes precedence over function signature
352
  # Start with function schema as base, then override with transformed schema
353
  final_schema = cls._merge_schema_with_precedence(
354
- parsed_fn.parameters, schema
355
  )
356
  else:
357
  # With **kwargs, function can access all transformed params
@@ -360,7 +357,7 @@ class TransformedTool(Tool):
360
 
361
  # Start with function schema as base, then override with transformed schema
362
  final_schema = cls._merge_schema_with_precedence(
363
- parsed_fn.parameters, schema
364
  )
365
 
366
  # Additional validation: check for naming conflicts after transformation
 
4
  from collections.abc import Callable
5
  from contextvars import ContextVar
6
  from dataclasses import dataclass
 
7
  from typing import Any, Literal
8
 
9
  from mcp.types import ContentBlock, ToolAnnotations
 
11
 
12
  from fastmcp.tools.tool import ParsedFunction, Tool
13
  from fastmcp.utilities.logging import get_logger
14
+ from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
16
  logger = get_logger(__name__)
17
 
 
 
18
 
19
  # Context variable to store current transformed tool
20
  _current_tool: ContextVar[TransformedTool | None] = ContextVar(
 
128
  ArgTransform(name="new_name", description="New desc", default=None, type=int)
129
  """
130
 
131
+ name: str | NotSetT = NotSet
132
+ description: str | NotSetT = NotSet
133
+ default: Any | NotSetT = NotSet
134
+ default_factory: Callable[[], Any] | NotSetT = NotSet
135
+ type: Any | NotSetT = NotSet
136
  hide: bool = False
137
+ required: Literal[True] | NotSetT = NotSet
138
+ examples: Any | NotSetT = NotSet
139
 
140
  def __post_init__(self):
141
  """Validate that only one of default or default_factory is provided."""
 
331
  has_kwargs = cls._function_has_kwargs(transform_fn)
332
 
333
  # Validate function parameters against transformed schema
334
+ fn_params = set(parsed_fn.input_schema.get("properties", {}).keys())
335
  transformed_params = set(schema.get("properties", {}).keys())
336
 
337
  if not has_kwargs:
 
348
  # ArgTransform takes precedence over function signature
349
  # Start with function schema as base, then override with transformed schema
350
  final_schema = cls._merge_schema_with_precedence(
351
+ parsed_fn.input_schema, schema
352
  )
353
  else:
354
  # With **kwargs, function can access all transformed params
 
357
 
358
  # Start with function schema as base, then override with transformed schema
359
  final_schema = cls._merge_schema_with_precedence(
360
+ parsed_fn.input_schema, schema
361
  )
362
 
363
  # Additional validation: check for naming conflicts after transformation
src/fastmcp/utilities/types.py CHANGED
@@ -6,21 +6,19 @@ import mimetypes
6
  from collections.abc import Callable
7
  from functools import lru_cache
8
  from pathlib import Path
9
- from types import UnionType
10
- from typing import Annotated, TypeVar, Union, get_args, get_origin
11
-
12
- from mcp.types import (
13
- Annotations,
14
- AudioContent,
15
- BlobResourceContents,
16
- EmbeddedResource,
17
- ImageContent,
18
- TextResourceContents,
19
- )
20
  from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
21
 
22
  T = TypeVar("T")
23
 
 
 
 
 
24
 
25
  class FastMCPBaseModel(BaseModel):
26
  """Base model for FastMCP models."""
@@ -129,7 +127,7 @@ class Image:
129
  self,
130
  mime_type: str | None = None,
131
  annotations: Annotations | None = None,
132
- ) -> ImageContent:
133
  """Convert to MCP ImageContent."""
134
  if self.path:
135
  with open(self.path, "rb") as f:
@@ -139,7 +137,7 @@ class Image:
139
  else:
140
  raise ValueError("No image data available")
141
 
142
- return ImageContent(
143
  type="image",
144
  data=data,
145
  mimeType=mime_type or self._mime_type,
@@ -188,7 +186,7 @@ class Audio:
188
  self,
189
  mime_type: str | None = None,
190
  annotations: Annotations | None = None,
191
- ) -> AudioContent:
192
  if self.path:
193
  with open(self.path, "rb") as f:
194
  data = base64.b64encode(f.read()).decode()
@@ -197,7 +195,7 @@ class Audio:
197
  else:
198
  raise ValueError("No audio data available")
199
 
200
- return AudioContent(
201
  type="audio",
202
  data=data,
203
  mimeType=mime_type or self._mime_type,
@@ -248,7 +246,7 @@ class File:
248
  self,
249
  mime_type: str | None = None,
250
  annotations: Annotations | None = None,
251
- ) -> EmbeddedResource:
252
  if self.path:
253
  with open(self.path, "rb") as f:
254
  raw_data = f.read()
@@ -271,21 +269,57 @@ class File:
271
  text = raw_data.decode("utf-8")
272
  except UnicodeDecodeError:
273
  text = raw_data.decode("latin-1")
274
- resource = TextResourceContents(
275
  text=text,
276
  mimeType=mime,
277
  uri=uri,
278
  )
279
  else:
280
  data = base64.b64encode(raw_data).decode()
281
- resource = BlobResourceContents(
282
  blob=data,
283
  mimeType=mime,
284
  uri=uri,
285
  )
286
 
287
- return EmbeddedResource(
288
  type="resource",
289
  resource=resource,
290
  annotations=annotations or self.annotations,
291
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from collections.abc import Callable
7
  from functools import lru_cache
8
  from pathlib import Path
9
+ from types import EllipsisType, UnionType
10
+ from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin
11
+
12
+ import mcp.types
13
+ from mcp.types import Annotations
 
 
 
 
 
 
14
  from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
15
 
16
  T = TypeVar("T")
17
 
18
+ # sentinel values for optional arguments
19
+ NotSet = ...
20
+ NotSetT: TypeAlias = EllipsisType
21
+
22
 
23
  class FastMCPBaseModel(BaseModel):
24
  """Base model for FastMCP models."""
 
127
  self,
128
  mime_type: str | None = None,
129
  annotations: Annotations | None = None,
130
+ ) -> mcp.types.ImageContent:
131
  """Convert to MCP ImageContent."""
132
  if self.path:
133
  with open(self.path, "rb") as f:
 
137
  else:
138
  raise ValueError("No image data available")
139
 
140
+ return mcp.types.ImageContent(
141
  type="image",
142
  data=data,
143
  mimeType=mime_type or self._mime_type,
 
186
  self,
187
  mime_type: str | None = None,
188
  annotations: Annotations | None = None,
189
+ ) -> mcp.types.AudioContent:
190
  if self.path:
191
  with open(self.path, "rb") as f:
192
  data = base64.b64encode(f.read()).decode()
 
195
  else:
196
  raise ValueError("No audio data available")
197
 
198
+ return mcp.types.AudioContent(
199
  type="audio",
200
  data=data,
201
  mimeType=mime_type or self._mime_type,
 
246
  self,
247
  mime_type: str | None = None,
248
  annotations: Annotations | None = None,
249
+ ) -> mcp.types.EmbeddedResource:
250
  if self.path:
251
  with open(self.path, "rb") as f:
252
  raw_data = f.read()
 
269
  text = raw_data.decode("utf-8")
270
  except UnicodeDecodeError:
271
  text = raw_data.decode("latin-1")
272
+ resource = mcp.types.TextResourceContents(
273
  text=text,
274
  mimeType=mime,
275
  uri=uri,
276
  )
277
  else:
278
  data = base64.b64encode(raw_data).decode()
279
+ resource = mcp.types.BlobResourceContents(
280
  blob=data,
281
  mimeType=mime,
282
  uri=uri,
283
  )
284
 
285
+ return mcp.types.EmbeddedResource(
286
  type="resource",
287
  resource=resource,
288
  annotations=annotations or self.annotations,
289
  )
290
+
291
+
292
+ def replace_type(type_, type_map: dict[type, type]):
293
+ """
294
+ Given a (possibly generic, nested, or otherwise complex) type, replaces all
295
+ instances of old_type with new_type.
296
+
297
+ This is useful for transforming types when creating tools.
298
+
299
+ Args:
300
+ type_: The type to replace instances of old_type with new_type.
301
+ old_type: The type to replace.
302
+ new_type: The type to replace old_type with.
303
+
304
+ Examples:
305
+ >>> replace_type(list[int | bool], {int: str})
306
+ list[str | bool]
307
+
308
+ >>> replace_type(list[list[int]], {int: str})
309
+ list[list[str]]
310
+
311
+ """
312
+ if type_ in type_map:
313
+ return type_map[type_]
314
+
315
+ origin = get_origin(type_)
316
+ if not origin:
317
+ return type_
318
+
319
+ args = get_args(type_)
320
+ new_args = tuple(replace_type(arg, type_map) for arg in args)
321
+
322
+ if origin is UnionType:
323
+ return Union[new_args] # type: ignore # noqa: UP007
324
+ else:
325
+ return origin[new_args]
tests/server/test_server_interactions.py CHANGED
@@ -17,7 +17,7 @@ from mcp.types import (
17
  TextContent,
18
  TextResourceContents,
19
  )
20
- from pydantic import AnyUrl, Field
21
 
22
  from fastmcp import Client, Context, FastMCP
23
  from fastmcp.client.transports import FastMCPTransport
@@ -826,6 +826,22 @@ class TestToolParameters:
826
  assert result[0].text == "0:16:40" # type: ignore[attr-defined]
827
 
828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  class TestToolContextInjection:
830
  """Test context injection in tools."""
831
 
 
17
  TextContent,
18
  TextResourceContents,
19
  )
20
+ from pydantic import AnyUrl, Field, TypeAdapter
21
 
22
  from fastmcp import Client, Context, FastMCP
23
  from fastmcp.client.transports import FastMCPTransport
 
826
  assert result[0].text == "0:16:40" # type: ignore[attr-defined]
827
 
828
 
829
+ class TestToolOutputSchema:
830
+ @pytest.mark.parametrize("annotation", [str, int, float, bool, list, dict, AnyUrl])
831
+ async def test_output_schema(self, annotation):
832
+ mcp = FastMCP()
833
+
834
+ @mcp.tool
835
+ def f() -> annotation: # type: ignore
836
+ return "hello"
837
+
838
+ async with Client(mcp) as client:
839
+ tools = await client.list_tools()
840
+ assert len(tools) == 1
841
+ # this line will fail until MCP adds output schemas!!
842
+ assert tools[0].outputSchema == TypeAdapter(annotation).json_schema() # type: ignore
843
+
844
+
845
  class TestToolContextInjection:
846
  """Test context injection in tools."""
847
 
tests/tools/test_tool.py CHANGED
@@ -1,4 +1,6 @@
1
  import json
 
 
2
 
3
  import pytest
4
  from mcp.types import (
@@ -8,7 +10,7 @@ from mcp.types import (
8
  TextContent,
9
  TextResourceContents,
10
  )
11
- from pydantic import AnyUrl, BaseModel
12
 
13
  from fastmcp import FastMCP
14
  from fastmcp.client import Client
@@ -33,6 +35,7 @@ class TestToolFromFunction:
33
  assert len(tool.parameters["properties"]) == 2
34
  assert tool.parameters["properties"]["a"]["type"] == "integer"
35
  assert tool.parameters["properties"]["b"]["type"] == "integer"
 
36
 
37
  async def test_async_function(self):
38
  """Test registering and running an async function."""
@@ -244,6 +247,129 @@ class TestToolFromFunction:
244
  assert result[0].text == "Custom serializer: 15"
245
 
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  class TestLegacyToolJsonParsing:
248
  """Tests for Tool's JSON pre-parsing functionality."""
249
 
 
1
  import json
2
+ from dataclasses import dataclass
3
+ from typing import Annotated, Any, TypedDict
4
 
5
  import pytest
6
  from mcp.types import (
 
10
  TextContent,
11
  TextResourceContents,
12
  )
13
+ from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
14
 
15
  from fastmcp import FastMCP
16
  from fastmcp.client import Client
 
35
  assert len(tool.parameters["properties"]) == 2
36
  assert tool.parameters["properties"]["a"]["type"] == "integer"
37
  assert tool.parameters["properties"]["b"]["type"] == "integer"
38
+ assert tool.output_schema == {"type": "integer"}
39
 
40
  async def test_async_function(self):
41
  """Test registering and running an async function."""
 
247
  assert result[0].text == "Custom serializer: 15"
248
 
249
 
250
+ class TestToolFromFunctionOutputSchema:
251
+ async def test_no_return_annotation(self):
252
+ def func():
253
+ pass
254
+
255
+ tool = Tool.from_function(func)
256
+ assert tool.output_schema is None
257
+
258
+ @pytest.mark.parametrize(
259
+ "annotation",
260
+ [
261
+ None,
262
+ int,
263
+ float,
264
+ bool,
265
+ str,
266
+ int | float,
267
+ list[int],
268
+ list[int | float],
269
+ dict[str, int | None],
270
+ tuple[int, str],
271
+ set[int],
272
+ list[tuple[int, str]],
273
+ ],
274
+ )
275
+ async def test_simple_return_annotation(self, annotation):
276
+ def func() -> annotation: # type: ignore
277
+ return 1
278
+
279
+ tool = Tool.from_function(func)
280
+ assert tool.output_schema == TypeAdapter(annotation).json_schema()
281
+
282
+ @pytest.mark.parametrize(
283
+ "annotation",
284
+ [
285
+ Any,
286
+ AnyUrl,
287
+ Annotated[int, Field(ge=1)],
288
+ Annotated[int, Field(ge=1)],
289
+ ],
290
+ )
291
+ async def test_complex_return_annotation(self, annotation):
292
+ def func() -> annotation: # type: ignore
293
+ return 1
294
+
295
+ tool = Tool.from_function(func)
296
+ assert tool.output_schema == TypeAdapter(annotation).json_schema()
297
+
298
+ @pytest.mark.parametrize(
299
+ "annotation, expected",
300
+ [
301
+ (Image, ImageContent),
302
+ (Audio, AudioContent),
303
+ (File, EmbeddedResource),
304
+ (Image | int, ImageContent | int),
305
+ (Image | Audio, ImageContent | AudioContent),
306
+ (list[Image | Audio], list[ImageContent | AudioContent]),
307
+ ],
308
+ )
309
+ async def test_converted_return_annotation(self, annotation, expected):
310
+ def func() -> annotation: # type: ignore
311
+ return 1
312
+
313
+ tool = Tool.from_function(func)
314
+ assert tool.output_schema == TypeAdapter(expected).json_schema()
315
+
316
+ async def test_dataclass_return_annotation(self):
317
+ @dataclass
318
+ class Person:
319
+ name: str
320
+ age: int
321
+
322
+ def func() -> Person:
323
+ return Person(name="John", age=30)
324
+
325
+ tool = Tool.from_function(func)
326
+ assert tool.output_schema == TypeAdapter(Person).json_schema()
327
+
328
+ async def test_base_model_return_annotation(self):
329
+ class Person(BaseModel):
330
+ name: str
331
+ age: int
332
+
333
+ def func() -> Person:
334
+ return Person(name="John", age=30)
335
+
336
+ tool = Tool.from_function(func)
337
+ assert tool.output_schema == TypeAdapter(Person).json_schema()
338
+
339
+ async def test_typeddict_return_annotation(self):
340
+ class Person(TypedDict):
341
+ name: str
342
+ age: int
343
+
344
+ def func() -> Person:
345
+ return Person(name="John", age=30)
346
+
347
+ tool = Tool.from_function(func)
348
+ assert tool.output_schema == TypeAdapter(Person).json_schema()
349
+
350
+ async def test_unserializable_return_annotation(self):
351
+ class Unserializable:
352
+ def __init__(self, data: Any):
353
+ self.data = data
354
+
355
+ def func() -> Unserializable:
356
+ return Unserializable(data="test")
357
+
358
+ tool = Tool.from_function(func)
359
+ assert tool.output_schema is None
360
+
361
+ async def test_mixed_unserializable_return_annotation(self):
362
+ class Unserializable:
363
+ def __init__(self, data: Any):
364
+ self.data = data
365
+
366
+ def func() -> Unserializable | int:
367
+ return Unserializable(data="test")
368
+
369
+ tool = Tool.from_function(func)
370
+ assert tool.output_schema is None
371
+
372
+
373
  class TestLegacyToolJsonParsing:
374
  """Tests for Tool's JSON pre-parsing functionality."""
375
 
tests/utilities/test_types.py CHANGED
@@ -12,6 +12,7 @@ from fastmcp.utilities.types import (
12
  find_kwarg_by_type,
13
  is_class_member_of_type,
14
  issubclass_safe,
 
15
  )
16
 
17
 
@@ -536,3 +537,29 @@ class TestFindKwargByType:
536
  pass
537
 
538
  assert find_kwarg_by_type(func, str) == "c"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  find_kwarg_by_type,
13
  is_class_member_of_type,
14
  issubclass_safe,
15
+ replace_type,
16
  )
17
 
18
 
 
537
  pass
538
 
539
  assert find_kwarg_by_type(func, str) == "c"
540
+
541
+
542
+ class TestReplaceType:
543
+ @pytest.mark.parametrize(
544
+ "input,type_map,expected",
545
+ [
546
+ (int, {}, int),
547
+ (int, {int: str}, str),
548
+ (int, {int: int}, int),
549
+ (int, {int: float, bool: str}, float),
550
+ (bool, {int: float, bool: str}, str),
551
+ (int, {int: list[int]}, list[int]),
552
+ (list[int], {int: str}, list[str]),
553
+ (list[int], {int: list[str]}, list[list[str]]),
554
+ (
555
+ list[int],
556
+ {int: float, list[int]: bool},
557
+ bool,
558
+ ), # list[int] will match before int
559
+ (list[int | bool], {int: str}, list[str | bool]),
560
+ (list[list[int]], {int: str}, list[list[str]]),
561
+ ],
562
+ )
563
+ def test_replace_type(self, input, type_map, expected):
564
+ """Test replacing a type with another type."""
565
+ assert replace_type(input, type_map) == expected