Jeremiah Lowin commited on
Commit
aa46c09
·
1 Parent(s): 7974ade

Add structured content & update almost all tests

Browse files
pyproject.toml CHANGED
@@ -12,6 +12,7 @@ dependencies = [
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "authlib>=1.5.2",
 
15
  ]
16
  requires-python = ">=3.10"
17
  readme = "README.md"
 
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "authlib>=1.5.2",
15
+ "pydantic[email]>=2.11.7",
16
  ]
17
  requires-python = ">=3.10"
18
  readme = "README.md"
src/fastmcp/client/client.py CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import asyncio
2
  import datetime
3
  from contextlib import AsyncExitStack, asynccontextmanager
 
4
  from pathlib import Path
5
  from typing import Any, Generic, Literal, cast, overload
6
 
@@ -10,7 +13,6 @@ import mcp.types
10
  import pydantic_core
11
  from exceptiongroup import catch
12
  from mcp import ClientSession
13
- from mcp.types import ContentBlock
14
  from pydantic import AnyUrl
15
 
16
  import fastmcp
@@ -31,7 +33,9 @@ from fastmcp.exceptions import ToolError
31
  from fastmcp.server import FastMCP
32
  from fastmcp.utilities.exceptions import get_catch_handlers
33
  from fastmcp.utilities.json_schema_type import json_schema_to_type
 
34
  from fastmcp.utilities.mcp_config import MCPConfig
 
35
 
36
  from .transports import (
37
  ClientTransportT,
@@ -57,6 +61,8 @@ __all__ = [
57
  "ProgressHandler",
58
  ]
59
 
 
 
60
 
61
  class Client(Generic[ClientTransportT]):
62
  """
@@ -100,34 +106,39 @@ class Client(Generic[ClientTransportT]):
100
  cls,
101
  transport: ClientTransportT,
102
  **kwargs: Any,
103
- ) -> "Client[ClientTransportT]": ...
104
 
105
  @overload
106
  def __new__(
107
  cls, transport: AnyUrl, **kwargs
108
- ) -> "Client[SSETransport|StreamableHttpTransport]": ...
109
 
110
  @overload
111
  def __new__(
112
  cls, transport: FastMCP | FastMCP1Server, **kwargs
113
- ) -> "Client[FastMCPTransport]": ...
114
 
115
  @overload
116
  def __new__(
117
  cls, transport: Path, **kwargs
118
- ) -> "Client[PythonStdioTransport|NodeStdioTransport]": ...
119
 
120
  @overload
121
  def __new__(
122
  cls, transport: MCPConfig | dict[str, Any], **kwargs
123
- ) -> "Client[MCPConfigTransport]": ...
124
 
125
  @overload
126
  def __new__(
127
  cls, transport: str, **kwargs
128
- ) -> "Client[PythonStdioTransport|NodeStdioTransport|SSETransport|StreamableHttpTransport]": ...
129
-
130
- def __new__(cls, transport, **kwargs) -> "Client":
 
 
 
 
 
131
  instance = super().__new__(cls)
132
  return instance
133
 
@@ -676,7 +687,8 @@ class Client(Generic[ClientTransportT]):
676
  arguments: dict[str, Any] | None = None,
677
  timeout: datetime.timedelta | float | int | None = None,
678
  progress_handler: ProgressHandler | None = None,
679
- ) -> list[ContentBlock] | dict[str, Any] | Any:
 
680
  """Call a tool on the server.
681
 
682
  Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
@@ -688,7 +700,7 @@ class Client(Generic[ClientTransportT]):
688
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
689
 
690
  Returns:
691
- list[ContentBlock] | dict[str, Any] | Any:
692
  The content returned by the tool. If the tool returns structured
693
  outputs, they are returned as a dataclass (if an output schema
694
  is available) or a dictionary; otherwise, a list of content
@@ -706,19 +718,43 @@ class Client(Generic[ClientTransportT]):
706
  timeout=timeout,
707
  progress_handler=progress_handler,
708
  )
709
- if result.isError:
 
710
  msg = cast(mcp.types.TextContent, result.content[0]).text
711
  raise ToolError(msg)
712
  elif result.structuredContent:
713
- if name not in self.session._tool_output_schemas:
714
- # refresh output schema cache
715
- await self.session.list_tools()
716
- if name in self.session._tool_output_schemas:
717
- output_schema = self.session._tool_output_schemas.get(name)
718
- if output_schema:
719
- output_type = json_schema_to_type(output_schema)
720
- return output_type(**result.structuredContent)
721
-
722
- return result.structuredContent
723
- else:
724
- return result.content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
  import asyncio
4
  import datetime
5
  from contextlib import AsyncExitStack, asynccontextmanager
6
+ from dataclasses import dataclass
7
  from pathlib import Path
8
  from typing import Any, Generic, Literal, cast, overload
9
 
 
13
  import pydantic_core
14
  from exceptiongroup import catch
15
  from mcp import ClientSession
 
16
  from pydantic import AnyUrl
17
 
18
  import fastmcp
 
33
  from fastmcp.server import FastMCP
34
  from fastmcp.utilities.exceptions import get_catch_handlers
35
  from fastmcp.utilities.json_schema_type import json_schema_to_type
36
+ from fastmcp.utilities.logging import get_logger
37
  from fastmcp.utilities.mcp_config import MCPConfig
38
+ from fastmcp.utilities.types import get_cached_typeadapter
39
 
40
  from .transports import (
41
  ClientTransportT,
 
61
  "ProgressHandler",
62
  ]
63
 
64
+ logger = get_logger(__name__)
65
+
66
 
67
  class Client(Generic[ClientTransportT]):
68
  """
 
106
  cls,
107
  transport: ClientTransportT,
108
  **kwargs: Any,
109
+ ) -> Client[ClientTransportT]: ...
110
 
111
  @overload
112
  def __new__(
113
  cls, transport: AnyUrl, **kwargs
114
+ ) -> Client[SSETransport | StreamableHttpTransport]: ...
115
 
116
  @overload
117
  def __new__(
118
  cls, transport: FastMCP | FastMCP1Server, **kwargs
119
+ ) -> Client[FastMCPTransport]: ...
120
 
121
  @overload
122
  def __new__(
123
  cls, transport: Path, **kwargs
124
+ ) -> Client[PythonStdioTransport | NodeStdioTransport]: ...
125
 
126
  @overload
127
  def __new__(
128
  cls, transport: MCPConfig | dict[str, Any], **kwargs
129
+ ) -> Client[MCPConfigTransport]: ...
130
 
131
  @overload
132
  def __new__(
133
  cls, transport: str, **kwargs
134
+ ) -> Client[
135
+ PythonStdioTransport
136
+ | NodeStdioTransport
137
+ | SSETransport
138
+ | StreamableHttpTransport
139
+ ]: ...
140
+
141
+ def __new__(cls, transport, **kwargs) -> Client:
142
  instance = super().__new__(cls)
143
  return instance
144
 
 
687
  arguments: dict[str, Any] | None = None,
688
  timeout: datetime.timedelta | float | int | None = None,
689
  progress_handler: ProgressHandler | None = None,
690
+ raise_on_error: bool = True,
691
+ ) -> CallToolResult:
692
  """Call a tool on the server.
693
 
694
  Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
 
700
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
701
 
702
  Returns:
703
+ CallToolResult:
704
  The content returned by the tool. If the tool returns structured
705
  outputs, they are returned as a dataclass (if an output schema
706
  is available) or a dictionary; otherwise, a list of content
 
718
  timeout=timeout,
719
  progress_handler=progress_handler,
720
  )
721
+ data = None
722
+ if result.isError and raise_on_error:
723
  msg = cast(mcp.types.TextContent, result.content[0]).text
724
  raise ToolError(msg)
725
  elif result.structuredContent:
726
+ try:
727
+ if name not in self.session._tool_output_schemas:
728
+ await self.session.list_tools()
729
+ if name in self.session._tool_output_schemas:
730
+ output_schema = self.session._tool_output_schemas.get(name)
731
+ if output_schema:
732
+ if output_schema.get("x-fastmcp-wrap-result"):
733
+ output_schema = output_schema.get("properties", {}).get(
734
+ "result"
735
+ )
736
+ structured_content = result.structuredContent.get("result")
737
+ else:
738
+ structured_content = result.structuredContent
739
+ output_type = json_schema_to_type(output_schema)
740
+ type_adapter = get_cached_typeadapter(output_type)
741
+ data = type_adapter.validate_python(structured_content)
742
+ else:
743
+ data = result.structuredContent
744
+ except Exception as e:
745
+ logger.error(f"Error parsing structured content: {e}")
746
+
747
+ return CallToolResult(
748
+ content=result.content,
749
+ structured_content=result.structuredContent,
750
+ data=data,
751
+ is_error=result.isError,
752
+ )
753
+
754
+
755
+ @dataclass
756
+ class CallToolResult:
757
+ content: list[mcp.types.ContentBlock]
758
+ structured_content: dict[str, Any] | None
759
+ data: Any = None
760
+ is_error: bool = False
src/fastmcp/server/low_level.py CHANGED
@@ -4,12 +4,14 @@ from mcp.server.lowlevel.server import (
4
  LifespanResultT,
5
  NotificationOptions,
6
  RequestT,
7
- Server,
 
 
8
  )
9
  from mcp.server.models import InitializationOptions
10
 
11
 
12
- class LowLevelServer(Server[LifespanResultT, RequestT]):
13
  def __init__(self, *args, **kwargs):
14
  super().__init__(*args, **kwargs)
15
  # FastMCP servers support notifications for all components
 
4
  LifespanResultT,
5
  NotificationOptions,
6
  RequestT,
7
+ )
8
+ from mcp.server.lowlevel.server import (
9
+ Server as _Server,
10
  )
11
  from mcp.server.models import InitializationOptions
12
 
13
 
14
+ class LowLevelServer(_Server[LifespanResultT, RequestT]):
15
  def __init__(self, *args, **kwargs):
16
  super().__init__(*args, **kwargs)
17
  # FastMCP servers support notifications for all components
src/fastmcp/server/openapi.py CHANGED
@@ -13,7 +13,7 @@ from re import Pattern
13
  from typing import TYPE_CHECKING, Any, Literal
14
 
15
  import httpx
16
- from mcp.types import ContentBlock, ToolAnnotations
17
  from pydantic.networks import AnyUrl
18
 
19
  import fastmcp
@@ -21,7 +21,7 @@ from fastmcp.exceptions import ToolError
21
  from fastmcp.resources import Resource, ResourceTemplate
22
  from fastmcp.server.dependencies import get_http_headers
23
  from fastmcp.server.server import FastMCP
24
- from fastmcp.tools.tool import Tool, _convert_to_content
25
  from fastmcp.utilities import openapi
26
  from fastmcp.utilities.logging import get_logger
27
  from fastmcp.utilities.openapi import (
@@ -254,7 +254,7 @@ class OpenAPITool(Tool):
254
  """Custom representation to prevent recursion errors when printing."""
255
  return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
256
 
257
- async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
258
  """Execute the HTTP request based on the route configuration."""
259
 
260
  # Prepare URL
@@ -450,10 +450,9 @@ class OpenAPITool(Tool):
450
  # Try to parse as JSON first
451
  try:
452
  result = response.json()
 
453
  except (json.JSONDecodeError, ValueError):
454
- # Return text content if not JSON
455
- result = response.text
456
- return _convert_to_content(result)
457
 
458
  except httpx.HTTPStatusError as e:
459
  # Handle HTTP errors (4xx, 5xx)
 
13
  from typing import TYPE_CHECKING, Any, Literal
14
 
15
  import httpx
16
+ from mcp.types import ToolAnnotations
17
  from pydantic.networks import AnyUrl
18
 
19
  import fastmcp
 
21
  from fastmcp.resources import Resource, ResourceTemplate
22
  from fastmcp.server.dependencies import get_http_headers
23
  from fastmcp.server.server import FastMCP
24
+ from fastmcp.tools.tool import Tool, ToolResult
25
  from fastmcp.utilities import openapi
26
  from fastmcp.utilities.logging import get_logger
27
  from fastmcp.utilities.openapi import (
 
254
  """Custom representation to prevent recursion errors when printing."""
255
  return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
256
 
257
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
258
  """Execute the HTTP request based on the route configuration."""
259
 
260
  # Prepare URL
 
450
  # Try to parse as JSON first
451
  try:
452
  result = response.json()
453
+ return ToolResult(structured_content=result)
454
  except (json.JSONDecodeError, ValueError):
455
+ return ToolResult(content=response.text)
 
 
456
 
457
  except httpx.HTTPStatusError as e:
458
  # Handle HTTP errors (4xx, 5xx)
src/fastmcp/server/proxy.py CHANGED
@@ -8,7 +8,6 @@ from mcp.shared.exceptions import McpError
8
  from mcp.types import (
9
  METHOD_NOT_FOUND,
10
  BlobResourceContents,
11
- ContentBlock,
12
  GetPromptResult,
13
  TextResourceContents,
14
  )
@@ -67,9 +66,7 @@ class ProxyToolManager(ToolManager):
67
  tools_dict = await self.get_tools()
68
  return list(tools_dict.values())
69
 
70
- async def call_tool(
71
- self, key: str, arguments: dict[str, Any]
72
- ) -> list[ContentBlock]:
73
  """Calls a tool, trying local/mounted first, then proxy if not found."""
74
  try:
75
  # First try local and mounted tools
@@ -77,7 +74,11 @@ class ProxyToolManager(ToolManager):
77
  except NotFoundError:
78
  # If not found locally, try proxy
79
  async with self.client:
80
- return await self.client.call_tool(key, arguments)
 
 
 
 
81
 
82
 
83
  class ProxyResourceManager(ResourceManager):
@@ -226,6 +227,7 @@ class ProxyTool(Tool):
226
  description=mcp_tool.description,
227
  parameters=mcp_tool.inputSchema,
228
  annotations=mcp_tool.annotations,
 
229
  )
230
 
231
  async def run(
@@ -244,7 +246,7 @@ class ProxyTool(Tool):
244
  raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
245
  return ToolResult(
246
  content=result.content,
247
- structured_output=result.structuredContent,
248
  )
249
 
250
 
 
8
  from mcp.types import (
9
  METHOD_NOT_FOUND,
10
  BlobResourceContents,
 
11
  GetPromptResult,
12
  TextResourceContents,
13
  )
 
66
  tools_dict = await self.get_tools()
67
  return list(tools_dict.values())
68
 
69
+ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
 
 
70
  """Calls a tool, trying local/mounted first, then proxy if not found."""
71
  try:
72
  # First try local and mounted tools
 
74
  except NotFoundError:
75
  # If not found locally, try proxy
76
  async with self.client:
77
+ result = await self.client.call_tool(key, arguments)
78
+ return ToolResult(
79
+ content=result.content,
80
+ structured_content=result.structured_content,
81
+ )
82
 
83
 
84
  class ProxyResourceManager(ResourceManager):
 
227
  description=mcp_tool.description,
228
  parameters=mcp_tool.inputSchema,
229
  annotations=mcp_tool.annotations,
230
+ output_schema=mcp_tool.outputSchema,
231
  )
232
 
233
  async def run(
 
246
  raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
247
  return ToolResult(
248
  content=result.content,
249
+ structured_content=result.structuredContent,
250
  )
251
 
252
 
src/fastmcp/server/server.py CHANGED
@@ -792,7 +792,7 @@ class FastMCP(Generic[LifespanResultT]):
792
  name: str | None = None,
793
  description: str | None = None,
794
  tags: set[str] | None = None,
795
- output_schema: dict[str, Any] | NotSetT = NotSet,
796
  annotations: ToolAnnotations | dict[str, Any] | None = None,
797
  exclude_args: list[str] | None = None,
798
  enabled: bool | None = None,
@@ -806,7 +806,7 @@ class FastMCP(Generic[LifespanResultT]):
806
  name: str | None = None,
807
  description: str | None = None,
808
  tags: set[str] | None = None,
809
- output_schema: dict[str, Any] | NotSetT = NotSet,
810
  annotations: ToolAnnotations | dict[str, Any] | None = None,
811
  exclude_args: list[str] | None = None,
812
  enabled: bool | None = None,
@@ -819,7 +819,7 @@ class FastMCP(Generic[LifespanResultT]):
819
  name: str | None = None,
820
  description: str | None = None,
821
  tags: set[str] | None = None,
822
- output_schema: dict[str, Any] | NotSetT = NotSet,
823
  annotations: ToolAnnotations | dict[str, Any] | None = None,
824
  exclude_args: list[str] | None = None,
825
  enabled: bool | None = None,
 
792
  name: str | None = None,
793
  description: str | None = None,
794
  tags: set[str] | None = None,
795
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
796
  annotations: ToolAnnotations | dict[str, Any] | None = None,
797
  exclude_args: list[str] | None = None,
798
  enabled: bool | None = None,
 
806
  name: str | None = None,
807
  description: str | None = None,
808
  tags: set[str] | None = None,
809
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
810
  annotations: ToolAnnotations | dict[str, Any] | None = None,
811
  exclude_args: list[str] | None = None,
812
  enabled: bool | None = None,
 
819
  name: str | None = None,
820
  description: str | None = None,
821
  tags: set[str] | None = None,
822
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
823
  annotations: ToolAnnotations | dict[str, Any] | None = None,
824
  exclude_args: list[str] | None = None,
825
  enabled: bool | None = None,
src/fastmcp/tools/tool.py CHANGED
@@ -5,6 +5,7 @@ from collections.abc import Callable
5
  from dataclasses import dataclass
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
 
8
  import pydantic_core
9
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
10
  from mcp.types import Tool as MCPTool
@@ -20,9 +21,9 @@ from fastmcp.utilities.types import (
20
  Image,
21
  NotSet,
22
  NotSetT,
23
- StructuredOutput,
24
  find_kwarg_by_type,
25
  get_cached_typeadapter,
 
26
  )
27
 
28
  if TYPE_CHECKING:
@@ -31,21 +32,47 @@ if TYPE_CHECKING:
31
  logger = get_logger(__name__)
32
 
33
 
 
 
 
 
34
  def default_serializer(data: Any) -> str:
35
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
36
 
37
 
38
- @dataclass
39
  class ToolResult:
40
- content: list[ContentBlock]
41
- structured_output: dict[str, Any] | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def to_mcp_result(
44
  self,
45
  ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
46
- if self.structured_output is None:
47
  return self.content
48
- return self.content, self.structured_output
49
 
50
 
51
  class Tool(FastMCPComponent):
@@ -159,18 +186,6 @@ class Tool(FastMCPComponent):
159
 
160
  class FunctionTool(Tool):
161
  fn: Callable[..., Any]
162
- wrap_primitive_output: bool = Field(
163
- default=False,
164
- description="""Whether to wrap the function's return value in a {"value": result} object.
165
-
166
- This is automatically set to True when a function has a primitive return type
167
- annotation (int, str, bool, etc.) and FastMCP auto-generates an object schema
168
- with a single "value" property to enable structured output support.
169
-
170
- When True, the function's raw return value gets wrapped as {"value": raw_result}
171
- in the structured output, allowing clients to receive properly typed objects
172
- even for primitive return types.""",
173
- )
174
 
175
  @classmethod
176
  def from_function(
@@ -192,17 +207,14 @@ class FunctionTool(Tool):
192
  if name is None and parsed_fn.name == "<lambda>":
193
  raise ValueError("You must provide a name for lambda functions")
194
 
195
- wrap_primitive_output = False
196
  if isinstance(output_schema, NotSetT):
197
  output_schema = parsed_fn.output_schema
198
- # convert primitive types to object with a single "value" property
199
  if output_schema and output_schema.get("type") != "object":
200
- wrap_primitive_output = True
201
  output_schema = {
202
  "type": "object",
203
- "properties": {"value": output_schema | {"title": "Value"}},
204
- "required": ["value"],
205
- "title": "Result",
206
  }
207
 
208
  return cls(
@@ -215,7 +227,6 @@ class FunctionTool(Tool):
215
  tags=tags or set(),
216
  serializer=serializer,
217
  enabled=enabled if enabled is not None else True,
218
- wrap_primitive_output=wrap_primitive_output,
219
  )
220
 
221
  async def run(self, arguments: dict[str, Any]) -> ToolResult:
@@ -233,21 +244,22 @@ class FunctionTool(Tool):
233
  if inspect.isawaitable(result):
234
  result = await result
235
 
 
 
 
236
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
237
 
238
- structured_output = None
239
- if isinstance(result, StructuredOutput):
240
- structured_output = result.to_structured_output()
241
- elif self.output_schema is not None:
242
- raw_result = pydantic_core.to_jsonable_python(result, fallback=str)
243
- if self.wrap_primitive_output:
244
- structured_output = {"value": raw_result}
245
  else:
246
- structured_output = raw_result
 
 
247
 
248
  return ToolResult(
249
  content=unstructured_result,
250
- structured_output=structured_output,
251
  )
252
 
253
 
@@ -264,6 +276,7 @@ class ParsedFunction:
264
  cls,
265
  fn: Callable[..., Any],
266
  exclude_args: list[str] | None = None,
 
267
  validate: bool = True,
268
  ) -> ParsedFunction:
269
  from fastmcp.server.context import Context
@@ -316,11 +329,34 @@ class ParsedFunction:
316
 
317
  output_schema = None
318
  output_type = inspect.signature(fn).return_annotation
319
- if output_type not in (inspect._empty, Image, Audio, File, StructuredOutput):
320
- try:
321
- output_type_adapter = get_cached_typeadapter(output_type)
322
- output_schema = output_type_adapter.json_schema()
323
- except PydanticSchemaGenerationError:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  logger.debug(f"Unable to generate schema for type {output_type!r}")
325
 
326
  return cls(
 
5
  from dataclasses import dataclass
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
8
+ import mcp.types
9
  import pydantic_core
10
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
  from mcp.types import Tool as MCPTool
 
21
  Image,
22
  NotSet,
23
  NotSetT,
 
24
  find_kwarg_by_type,
25
  get_cached_typeadapter,
26
+ replace_type,
27
  )
28
 
29
  if TYPE_CHECKING:
 
32
  logger = get_logger(__name__)
33
 
34
 
35
+ class _UnserializableType:
36
+ pass
37
+
38
+
39
  def default_serializer(data: Any) -> str:
40
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
41
 
42
 
 
43
  class ToolResult:
44
+ def __init__(
45
+ self,
46
+ content: list[ContentBlock] | Any | None = None,
47
+ structured_content: dict[str, Any] | Any | None = None,
48
+ ):
49
+ if content is None and structured_content is None:
50
+ raise ValueError("Either content or structured_content must be provided")
51
+ elif content is None:
52
+ content = structured_content
53
+
54
+ self.content = _convert_to_content(content)
55
+
56
+ if structured_content is not None:
57
+ try:
58
+ structured_content = pydantic_core.to_jsonable_python(
59
+ structured_content
60
+ )
61
+ except pydantic_core.PydanticSerializationError:
62
+ logger.error(
63
+ "Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization:"
64
+ )
65
+ raise
66
+ if not isinstance(structured_content, dict):
67
+ structured_content = {"result": structured_content}
68
+ self.structured_content: dict[str, Any] | None = structured_content
69
 
70
  def to_mcp_result(
71
  self,
72
  ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
73
+ if self.structured_content is None:
74
  return self.content
75
+ return self.content, self.structured_content
76
 
77
 
78
  class Tool(FastMCPComponent):
 
186
 
187
  class FunctionTool(Tool):
188
  fn: Callable[..., Any]
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  @classmethod
191
  def from_function(
 
207
  if name is None and parsed_fn.name == "<lambda>":
208
  raise ValueError("You must provide a name for lambda functions")
209
 
 
210
  if isinstance(output_schema, NotSetT):
211
  output_schema = parsed_fn.output_schema
212
+
213
  if output_schema and output_schema.get("type") != "object":
 
214
  output_schema = {
215
  "type": "object",
216
+ "properties": {"result": output_schema},
217
+ "x-fastmcp-wrap-result": True,
 
218
  }
219
 
220
  return cls(
 
227
  tags=tags or set(),
228
  serializer=serializer,
229
  enabled=enabled if enabled is not None else True,
 
230
  )
231
 
232
  async def run(self, arguments: dict[str, Any]) -> ToolResult:
 
244
  if inspect.isawaitable(result):
245
  result = await result
246
 
247
+ if isinstance(result, ToolResult):
248
+ return result
249
+
250
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
251
 
252
+ if self.output_schema is not None:
253
+ if self.output_schema.get("x-fastmcp-wrap-result"):
254
+ structured_output = {"result": result}
 
 
 
 
255
  else:
256
+ structured_output = result
257
+ else:
258
+ structured_output = None
259
 
260
  return ToolResult(
261
  content=unstructured_result,
262
+ structured_content=structured_output,
263
  )
264
 
265
 
 
276
  cls,
277
  fn: Callable[..., Any],
278
  exclude_args: list[str] | None = None,
279
+ ignore_response_types: list[type] | None = None,
280
  validate: bool = True,
281
  ) -> ParsedFunction:
282
  from fastmcp.server.context import Context
 
329
 
330
  output_schema = None
331
  output_type = inspect.signature(fn).return_annotation
332
+
333
+ # there are a variety of types that we don't want to attempt to
334
+ # serialize because they are either used by FastMCP internally,
335
+ # or are MCP content types that explicitly don't form structured
336
+ # content. By replacing them with an explicitly unserializable type,
337
+ # we ensure that no output schema is automatically generated.
338
+
339
+ output_type = replace_type(
340
+ output_type,
341
+ {
342
+ inspect._empty: _UnserializableType,
343
+ Image: _UnserializableType,
344
+ Audio: _UnserializableType,
345
+ File: _UnserializableType,
346
+ ToolResult: _UnserializableType,
347
+ mcp.types.TextContent: _UnserializableType,
348
+ mcp.types.ImageContent: _UnserializableType,
349
+ mcp.types.AudioContent: _UnserializableType,
350
+ mcp.types.ResourceLink: _UnserializableType,
351
+ mcp.types.EmbeddedResource: _UnserializableType,
352
+ },
353
+ )
354
+
355
+ try:
356
+ output_type_adapter = get_cached_typeadapter(output_type)
357
+ output_schema = output_type_adapter.json_schema()
358
+ except PydanticSchemaGenerationError as e:
359
+ if "_UnserializableType" not in str(e):
360
  logger.debug(f"Unable to generate schema for type {output_type!r}")
361
 
362
  return cls(
src/fastmcp/utilities/json_schema_type.py CHANGED
@@ -53,6 +53,8 @@ from typing import (
53
 
54
  from pydantic import (
55
  AnyUrl,
 
 
56
  EmailStr,
57
  Field,
58
  Json,
@@ -167,6 +169,13 @@ def json_schema_to_type(
167
  """
168
  # Always use the top-level schema for references
169
  if schema.get("type") == "object":
 
 
 
 
 
 
 
170
  return _create_dataclass(schema, name, schemas=schema)
171
  elif name:
172
  raise ValueError(f"Can not apply name to non-object schema: {name}")
@@ -285,7 +294,11 @@ def _get_from_type_handler(
285
  "boolean": lambda _: bool, # type: ignore
286
  "null": lambda _: type(None), # type: ignore
287
  "array": lambda s: _create_array_type(s, schemas), # type: ignore
288
- "object": lambda s: _create_dataclass(s, s.get("title"), schemas), # type: ignore
 
 
 
 
289
  }
290
  return type_handlers.get(schema.get("type", None), _return_Any)
291
 
@@ -329,7 +342,10 @@ def _schema_to_type(
329
  has_null = type(None) in types
330
  types = [t for t in types if t is not type(None)]
331
  if has_null:
332
- return Optional[tuple(types) if len(types) > 1 else types[0]] # type: ignore # noqa: UP007
 
 
 
333
  return Union[tuple(types)] # type: ignore # noqa: UP007
334
 
335
  return _get_from_type_handler(schema, schemas)(schema)
@@ -378,6 +394,62 @@ def _create_field_with_default(
378
  return field(default=default_value)
379
 
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  def _create_dataclass(
382
  schema: Mapping[str, Any],
383
  name: str | None = None,
 
53
 
54
  from pydantic import (
55
  AnyUrl,
56
+ BaseModel,
57
+ ConfigDict,
58
  EmailStr,
59
  Field,
60
  Json,
 
169
  """
170
  # Always use the top-level schema for references
171
  if schema.get("type") == "object":
172
+ # If no properties defined but additionalProperties is True, return dict[str, Any]
173
+ if not schema.get("properties") and schema.get("additionalProperties") is True:
174
+ return dict[str, Any] # type: ignore
175
+ # If has properties AND additionalProperties is True, use Pydantic BaseModel
176
+ elif schema.get("properties") and schema.get("additionalProperties") is True:
177
+ return _create_pydantic_model(schema, name, schemas=schema)
178
+ # Otherwise use fast dataclass
179
  return _create_dataclass(schema, name, schemas=schema)
180
  elif name:
181
  raise ValueError(f"Can not apply name to non-object schema: {name}")
 
294
  "boolean": lambda _: bool, # type: ignore
295
  "null": lambda _: type(None), # type: ignore
296
  "array": lambda s: _create_array_type(s, schemas), # type: ignore
297
+ "object": lambda s: (
298
+ _create_pydantic_model(s, s.get("title"), schemas)
299
+ if s.get("properties") and s.get("additionalProperties") is True
300
+ else _create_dataclass(s, s.get("title"), schemas)
301
+ ), # type: ignore
302
  }
303
  return type_handlers.get(schema.get("type", None), _return_Any)
304
 
 
342
  has_null = type(None) in types
343
  types = [t for t in types if t is not type(None)]
344
  if has_null:
345
+ if len(types) == 1:
346
+ return Optional[types[0]] # type: ignore # noqa: UP007
347
+ else:
348
+ return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007
349
  return Union[tuple(types)] # type: ignore # noqa: UP007
350
 
351
  return _get_from_type_handler(schema, schemas)(schema)
 
394
  return field(default=default_value)
395
 
396
 
397
+ def _create_pydantic_model(
398
+ schema: Mapping[str, Any],
399
+ name: str | None = None,
400
+ schemas: Mapping[str, Any] | None = None,
401
+ ) -> type:
402
+ """Create Pydantic BaseModel from object schema with additionalProperties."""
403
+ name = name or schema.get("title", "Root")
404
+ sanitized_name = _sanitize_name(name)
405
+ schema_hash = _hash_schema(schema)
406
+ cache_key = (schema_hash, sanitized_name)
407
+
408
+ # Return existing class if already built
409
+ if cache_key in _classes:
410
+ existing = _classes[cache_key]
411
+ if existing is None:
412
+ return ForwardRef(sanitized_name)
413
+ return existing
414
+
415
+ # Place placeholder for recursive references
416
+ _classes[cache_key] = None
417
+
418
+ properties = schema.get("properties", {})
419
+ required = schema.get("required", [])
420
+
421
+ # Build field annotations and defaults
422
+ annotations = {}
423
+ defaults = {}
424
+
425
+ for prop_name, prop_schema in properties.items():
426
+ field_type = _schema_to_type(prop_schema, schemas or {})
427
+
428
+ # Handle defaults
429
+ default_value = prop_schema.get("default", MISSING)
430
+ if default_value is not MISSING:
431
+ defaults[prop_name] = default_value
432
+ annotations[prop_name] = field_type
433
+ elif prop_name in required:
434
+ annotations[prop_name] = field_type
435
+ else:
436
+ annotations[prop_name] = Optional[field_type]
437
+ defaults[prop_name] = None
438
+
439
+ # Create Pydantic model class
440
+ cls_dict = {
441
+ "__annotations__": annotations,
442
+ "model_config": ConfigDict(extra="allow"),
443
+ **defaults,
444
+ }
445
+
446
+ cls = type(sanitized_name, (BaseModel,), cls_dict)
447
+
448
+ # Store completed class
449
+ _classes[cache_key] = cls
450
+ return cls
451
+
452
+
453
  def _create_dataclass(
454
  schema: Mapping[str, Any],
455
  name: str | None = None,
src/fastmcp/utilities/types.py CHANGED
@@ -7,7 +7,7 @@ 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, Any, TypeAlias, TypeVar, Union, get_args, get_origin
11
 
12
  import mcp.types
13
  from mcp.types import Annotations
@@ -289,16 +289,6 @@ class File:
289
  )
290
 
291
 
292
- class StructuredOutput:
293
- """Helper class for returning structured output from tools."""
294
-
295
- def __init__(self, data: dict[str, Any]):
296
- self.data = data
297
-
298
- def to_structured_output(self) -> dict[str, Any]:
299
- return self.data
300
-
301
-
302
  def replace_type(type_, type_map: dict[type, type]):
303
  """
304
  Given a (possibly generic, nested, or otherwise complex) type, replaces all
 
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
 
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
tests/auth/test_oauth_client.py CHANGED
@@ -226,7 +226,9 @@ async def test_call_tool(client_with_headless_oauth: Client):
226
  """Test that we can call a tool."""
227
  async with client_with_headless_oauth:
228
  result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
229
- assert result[0].text == "8" # type: ignore[attr-defined]
 
 
230
 
231
 
232
  async def test_list_resources(client_with_headless_oauth: Client):
 
226
  """Test that we can call a tool."""
227
  async with client_with_headless_oauth:
228
  result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
229
+ # The add tool returns int which gets wrapped as structured output
230
+ # Client unwraps it and puts the actual int in the data field
231
+ assert result.data == 8
232
 
233
 
234
  async def test_list_resources(client_with_headless_oauth: Client):
tests/client/test_client.py CHANGED
@@ -121,9 +121,10 @@ async def test_call_tool(fastmcp_server):
121
  async with client:
122
  result = await client.call_tool("greet", {"name": "World"})
123
 
124
- # The result content should contain our greeting
125
- content_str = str(result[0])
126
- assert "Hello, World!" in content_str
 
127
 
128
 
129
  async def test_call_tool_mcp(fastmcp_server):
 
121
  async with client:
122
  result = await client.call_tool("greet", {"name": "World"})
123
 
124
+ assert result.content[0].text == "Hello, World!" # type: ignore[attr-defined]
125
+ assert result.structured_content == {"result": "Hello, World!"}
126
+ assert result.data == "Hello, World!"
127
+ assert result.is_error is False
128
 
129
 
130
  async def test_call_tool_mcp(fastmcp_server):
tests/client/test_notifications.py CHANGED
@@ -126,7 +126,7 @@ class TestToolNotifications:
126
 
127
  # Enable the target tool
128
  result = await client.call_tool("enable_target_tool", {})
129
- assert result[0].text == "Target tool enabled" # type: ignore[attr-defined]
130
 
131
  # Check that notification was sent
132
  recording_message_handler.assert_notification_sent(
@@ -147,7 +147,7 @@ class TestToolNotifications:
147
 
148
  # Disable the target tool
149
  result = await client.call_tool("disable_target_tool", {})
150
- assert result[0].text == "Target tool disabled" # type: ignore[attr-defined]
151
 
152
  # Check that notification was sent
153
  recording_message_handler.assert_notification_sent(
@@ -231,7 +231,7 @@ class TestResourceNotifications:
231
 
232
  # Enable the target resource
233
  result = await client.call_tool("enable_target_resource", {})
234
- assert result[0].text == "Target resource enabled" # type: ignore[attr-defined]
235
 
236
  # Check that notification was sent
237
  recording_message_handler.assert_notification_sent(
@@ -252,7 +252,7 @@ class TestResourceNotifications:
252
 
253
  # Disable the target resource
254
  result = await client.call_tool("disable_target_resource", {})
255
- assert result[0].text == "Target resource disabled" # type: ignore[attr-defined]
256
 
257
  # Check that notification was sent
258
  recording_message_handler.assert_notification_sent(
@@ -313,7 +313,7 @@ class TestPromptNotifications:
313
 
314
  # Enable the target prompt
315
  result = await client.call_tool("enable_target_prompt", {})
316
- assert result[0].text == "Target prompt enabled" # type: ignore[attr-defined]
317
 
318
  # Check that notification was sent
319
  recording_message_handler.assert_notification_sent(
@@ -334,7 +334,7 @@ class TestPromptNotifications:
334
 
335
  # Disable the target prompt
336
  result = await client.call_tool("disable_target_prompt", {})
337
- assert result[0].text == "Target prompt disabled" # type: ignore[attr-defined]
338
 
339
  # Check that notification was sent
340
  recording_message_handler.assert_notification_sent(
 
126
 
127
  # Enable the target tool
128
  result = await client.call_tool("enable_target_tool", {})
129
+ assert result.data == "Target tool enabled"
130
 
131
  # Check that notification was sent
132
  recording_message_handler.assert_notification_sent(
 
147
 
148
  # Disable the target tool
149
  result = await client.call_tool("disable_target_tool", {})
150
+ assert result.data == "Target tool disabled"
151
 
152
  # Check that notification was sent
153
  recording_message_handler.assert_notification_sent(
 
231
 
232
  # Enable the target resource
233
  result = await client.call_tool("enable_target_resource", {})
234
+ assert result.data == "Target resource enabled"
235
 
236
  # Check that notification was sent
237
  recording_message_handler.assert_notification_sent(
 
252
 
253
  # Disable the target resource
254
  result = await client.call_tool("disable_target_resource", {})
255
+ assert result.data == "Target resource disabled"
256
 
257
  # Check that notification was sent
258
  recording_message_handler.assert_notification_sent(
 
313
 
314
  # Enable the target prompt
315
  result = await client.call_tool("enable_target_prompt", {})
316
+ assert result.data == "Target prompt enabled"
317
 
318
  # Check that notification was sent
319
  recording_message_handler.assert_notification_sent(
 
334
 
335
  # Disable the target prompt
336
  result = await client.call_tool("disable_target_prompt", {})
337
+ assert result.data == "Target prompt disabled"
338
 
339
  # Check that notification was sent
340
  recording_message_handler.assert_notification_sent(
tests/client/test_openapi.py CHANGED
@@ -118,7 +118,7 @@ class TestClientHeaders:
118
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
119
  ) as client:
120
  result = await client.call_tool("post_headers_headers_post")
121
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
122
  assert headers["x-test"] == "test-123"
123
 
124
  async def test_client_headers_shttp_tool(self, shttp_server: str):
@@ -128,7 +128,7 @@ class TestClientHeaders:
128
  )
129
  ) as client:
130
  result = await client.call_tool("post_headers_headers_post")
131
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
132
  assert headers["x-test"] == "test-123"
133
 
134
  async def test_client_overrides_server_headers(self, shttp_server: str):
 
118
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
119
  ) as client:
120
  result = await client.call_tool("post_headers_headers_post")
121
+ headers: dict[str, str] = result.data
122
  assert headers["x-test"] == "test-123"
123
 
124
  async def test_client_headers_shttp_tool(self, shttp_server: str):
 
128
  )
129
  ) as client:
130
  result = await client.call_tool("post_headers_headers_post")
131
+ headers: dict[str, str] = result.data
132
  assert headers["x-test"] == "test-123"
133
 
134
  async def test_client_overrides_server_headers(self, shttp_server: str):
tests/client/test_roots.py CHANGED
@@ -1,5 +1,3 @@
1
- import json
2
-
3
  import pytest
4
 
5
  from fastmcp import Client, Context, FastMCP
@@ -40,7 +38,7 @@ class TestClientRoots:
40
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
41
  async with Client(fastmcp_server, roots=roots) as client:
42
  result = await client.call_tool("list_roots", {})
43
- assert json.loads(result[0].text) == [ # type: ignore[attr-defined]
44
  "file://x/y/z",
45
  "file://x/y/z",
46
  ]
 
 
 
1
  import pytest
2
 
3
  from fastmcp import Client, Context, FastMCP
 
38
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
39
  async with Client(fastmcp_server, roots=roots) as client:
40
  result = await client.call_tool("list_roots", {})
41
+ assert result.data == [
42
  "file://x/y/z",
43
  "file://x/y/z",
44
  ]
tests/client/test_sampling.py CHANGED
@@ -47,8 +47,7 @@ async def test_simple_sampling(fastmcp_server: FastMCP):
47
 
48
  async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
49
  result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
50
- reply = cast(TextContent, result[0])
51
- assert reply.text == "This is the sample message!"
52
 
53
 
54
  async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
@@ -62,8 +61,7 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
62
  result = await client.call_tool(
63
  "sample_with_system_prompt", {"message": "Hello, world!"}
64
  )
65
- reply = cast(TextContent, result[0])
66
- assert reply.text == "You love FastMCP"
67
 
68
 
69
  async def test_sampling_with_messages(fastmcp_server: FastMCP):
@@ -81,5 +79,4 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
81
  result = await client.call_tool(
82
  "sample_with_messages", {"message": "Hello, world!"}
83
  )
84
- reply = cast(TextContent, result[0])
85
- assert reply.text == "I need to think."
 
47
 
48
  async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
49
  result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
50
+ assert result.data == "This is the sample message!"
 
51
 
52
 
53
  async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
 
61
  result = await client.call_tool(
62
  "sample_with_system_prompt", {"message": "Hello, world!"}
63
  )
64
+ assert result.data == "You love FastMCP"
 
65
 
66
 
67
  async def test_sampling_with_messages(fastmcp_server: FastMCP):
 
79
  result = await client.call_tool(
80
  "sample_with_messages", {"message": "Hello, world!"}
81
  )
82
+ assert result.data == "I need to think."
 
tests/client/test_stdio.py CHANGED
@@ -48,11 +48,11 @@ class TestKeepAlive:
48
 
49
  async with client:
50
  result1 = await client.call_tool("pid")
51
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
52
 
53
  async with client:
54
  result2 = await client.call_tool("pid")
55
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
56
 
57
  assert pid1 == pid2
58
 
@@ -66,11 +66,11 @@ class TestKeepAlive:
66
 
67
  async with client:
68
  result1 = await client.call_tool("pid")
69
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
70
 
71
  async with client:
72
  result2 = await client.call_tool("pid")
73
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
74
 
75
  assert pid1 != pid2
76
 
@@ -80,13 +80,13 @@ class TestKeepAlive:
80
 
81
  async with client:
82
  result1 = await client.call_tool("pid")
83
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
84
 
85
  await client.close()
86
 
87
  async with client:
88
  result2 = await client.call_tool("pid")
89
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
90
 
91
  assert pid1 != pid2
92
 
@@ -96,14 +96,14 @@ class TestKeepAlive:
96
 
97
  async with client:
98
  result1 = await client.call_tool("pid")
99
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
100
 
101
  async with client:
102
  result2 = await client.call_tool("pid")
103
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
104
 
105
  result3 = await client.call_tool("pid")
106
- pid3 = int(result3[0].text) # type: ignore[attr-defined]
107
 
108
  assert pid1 == pid2 == pid3
109
 
 
48
 
49
  async with client:
50
  result1 = await client.call_tool("pid")
51
+ pid1: int = result1.data
52
 
53
  async with client:
54
  result2 = await client.call_tool("pid")
55
+ pid2: int = result2.data
56
 
57
  assert pid1 == pid2
58
 
 
66
 
67
  async with client:
68
  result1 = await client.call_tool("pid")
69
+ pid1: int = result1.data
70
 
71
  async with client:
72
  result2 = await client.call_tool("pid")
73
+ pid2: int = result2.data
74
 
75
  assert pid1 != pid2
76
 
 
80
 
81
  async with client:
82
  result1 = await client.call_tool("pid")
83
+ pid1: int = result1.data
84
 
85
  await client.close()
86
 
87
  async with client:
88
  result2 = await client.call_tool("pid")
89
+ pid2: int = result2.data
90
 
91
  assert pid1 != pid2
92
 
 
96
 
97
  async with client:
98
  result1 = await client.call_tool("pid")
99
+ pid1: int = result1.data
100
 
101
  async with client:
102
  result2 = await client.call_tool("pid")
103
+ pid2: int = result2.data
104
 
105
  result3 = await client.call_tool("pid")
106
+ pid3: int = result3.data
107
 
108
  assert pid1 == pid2 == pid3
109
 
tests/client/test_streamable_http.py CHANGED
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock
7
  import pytest
8
  import uvicorn
9
  from mcp import McpError
10
- from mcp.types import TextContent
11
  from starlette.applications import Starlette
12
  from starlette.routing import Mount
13
 
@@ -166,10 +165,7 @@ async def test_greet_with_progress_tool(streamable_http_server: str):
166
  progress_handler=progress_handler,
167
  ) as client:
168
  result = await client.call_tool("greet_with_progress", {"name": "Alice"})
169
-
170
- assert isinstance(result, list)
171
- assert isinstance(result[0], TextContent)
172
- assert result[0].text == "Hello, Alice!"
173
 
174
  progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress")
175
 
 
7
  import pytest
8
  import uvicorn
9
  from mcp import McpError
 
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
12
 
 
165
  progress_handler=progress_handler,
166
  ) as client:
167
  result = await client.call_tool("greet_with_progress", {"name": "Alice"})
168
+ assert result.data == "Hello, Alice!"
 
 
 
169
 
170
  progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress")
171
 
tests/deprecated/test_mount_import_arg_order.py CHANGED
@@ -36,7 +36,7 @@ class TestDeprecatedMountArgOrder:
36
  # Test functionality
37
  async with Client(main_app) as client:
38
  result = await client.call_tool("sub_sub_tool", {})
39
- assert result[0].text == "Sub tool result" # type: ignore[attr-defined]
40
 
41
  async def test_mount_new_arg_order_no_warning(self):
42
  """Test that mount(server, prefix) works without deprecation warning."""
@@ -122,7 +122,7 @@ class TestDeprecatedImportArgOrder:
122
  # Test functionality
123
  async with Client(main_app) as client:
124
  result = await client.call_tool("sub_sub_tool", {})
125
- assert result[0].text == "Sub tool result" # type: ignore[attr-defined]
126
 
127
  async def test_import_new_arg_order_no_warning(self):
128
  """Test that import_server(server, prefix) works without deprecation warning."""
 
36
  # Test functionality
37
  async with Client(main_app) as client:
38
  result = await client.call_tool("sub_sub_tool", {})
39
+ assert result.data == "Sub tool result"
40
 
41
  async def test_mount_new_arg_order_no_warning(self):
42
  """Test that mount(server, prefix) works without deprecation warning."""
 
122
  # Test functionality
123
  async with Client(main_app) as client:
124
  result = await client.call_tool("sub_sub_tool", {})
125
+ assert result.data == "Sub tool result"
126
 
127
  async def test_import_new_arg_order_no_warning(self):
128
  """Test that import_server(server, prefix) works without deprecation warning."""
tests/server/openapi/test_openapi.py CHANGED
@@ -269,9 +269,8 @@ class TestTools:
269
  "create_user_users_post", {"name": "David", "active": False}
270
  )
271
 
272
- response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
273
  expected_user = User(id=4, name="David", active=False).model_dump()
274
- assert response_data == expected_user
275
 
276
  # Check that the user was created via API
277
  response = await api_client.get("/users")
@@ -298,9 +297,8 @@ class TestTools:
298
  {"user_id": 1, "name": "XYZ"},
299
  )
300
 
301
- response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
302
  expected_data = dict(id=1, name="XYZ", active=True)
303
- assert response_data == expected_data
304
 
305
  # Check that the user was updated via API
306
  response = await api_client.get("/users")
@@ -332,10 +330,12 @@ class TestTools:
332
  )
333
  async with Client(mcp_server) as client:
334
  tool_response = await client.call_tool("get_users_users_get", {})
335
- assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined]
336
- user.model_dump()
337
- for user in sorted(users_db.values(), key=lambda x: x.id)
338
- ]
 
 
339
 
340
 
341
  class TestResources:
@@ -729,12 +729,22 @@ class TestOpenAPI30Compatibility:
729
  "createProduct", {"name": "New Product", "price": 39.99}
730
  )
731
  # Result should be a text content
732
- assert len(result) == 1
733
- product = json.loads(result[0].text) # type: ignore[attr-defined]
734
  assert product["id"] == "p3"
735
  assert product["name"] == "New Product"
736
  assert product["price"] == 39.99
737
 
 
 
 
 
 
 
 
 
 
 
738
 
739
  class TestOpenAPI31Compatibility:
740
  """Tests for compatibility with OpenAPI 3.1 specifications."""
@@ -905,12 +915,22 @@ class TestOpenAPI31Compatibility:
905
  "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
906
  )
907
  # Result should be a text content
908
- assert len(result) == 1
909
- order = json.loads(result[0].text) # type: ignore[attr-dict]
910
  assert order["id"] == "o3"
911
  assert order["customer"] == "Charlie"
912
  assert order["items"] == ["item4", "item5"]
913
 
 
 
 
 
 
 
 
 
 
 
914
 
915
  async def test_empty_query_parameters_not_sent(
916
  fastapi_app: FastAPI, api_client: httpx.AsyncClient
 
269
  "create_user_users_post", {"name": "David", "active": False}
270
  )
271
 
 
272
  expected_user = User(id=4, name="David", active=False).model_dump()
273
+ assert tool_response.data == expected_user
274
 
275
  # Check that the user was created via API
276
  response = await api_client.get("/users")
 
297
  {"user_id": 1, "name": "XYZ"},
298
  )
299
 
 
300
  expected_data = dict(id=1, name="XYZ", active=True)
301
+ assert tool_response.data == expected_data
302
 
303
  # Check that the user was updated via API
304
  response = await api_client.get("/users")
 
330
  )
331
  async with Client(mcp_server) as client:
332
  tool_response = await client.call_tool("get_users_users_get", {})
333
+ assert tool_response.data == {
334
+ "result": [
335
+ user.model_dump()
336
+ for user in sorted(users_db.values(), key=lambda x: x.id)
337
+ ]
338
+ }
339
 
340
 
341
  class TestResources:
 
729
  "createProduct", {"name": "New Product", "price": 39.99}
730
  )
731
  # Result should be a text content
732
+ assert len(result.content) == 1
733
+ product = json.loads(result.content[0].text) # type: ignore[attr-defined]
734
  assert product["id"] == "p3"
735
  assert product["name"] == "New Product"
736
  assert product["price"] == 39.99
737
 
738
+ assert result.structured_content is not None
739
+ assert result.structured_content["id"] == "p3"
740
+ assert result.structured_content["name"] == "New Product"
741
+ assert result.structured_content["price"] == 39.99
742
+
743
+ assert result.data is not None
744
+ assert result.data["id"] == "p3"
745
+ assert result.data["name"] == "New Product"
746
+ assert result.data["price"] == 39.99
747
+
748
 
749
  class TestOpenAPI31Compatibility:
750
  """Tests for compatibility with OpenAPI 3.1 specifications."""
 
915
  "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
916
  )
917
  # Result should be a text content
918
+ assert len(result.content) == 1
919
+ order = json.loads(result.content[0].text) # type: ignore[attr-dict]
920
  assert order["id"] == "o3"
921
  assert order["customer"] == "Charlie"
922
  assert order["items"] == ["item4", "item5"]
923
 
924
+ assert result.structured_content is not None
925
+ assert result.structured_content["id"] == "o3"
926
+ assert result.structured_content["customer"] == "Charlie"
927
+ assert result.structured_content["items"] == ["item4", "item5"]
928
+
929
+ assert result.data is not None
930
+ assert result.data["id"] == "o3"
931
+ assert result.data["customer"] == "Charlie"
932
+ assert result.data["items"] == ["item4", "item5"]
933
+
934
 
935
  async def test_empty_query_parameters_not_sent(
936
  fastapi_app: FastAPI, api_client: httpx.AsyncClient
tests/server/openapi/test_openapi_path_parameters.py CHANGED
@@ -301,20 +301,11 @@ async def test_array_query_param_with_fastapi():
301
 
302
  # Single day
303
  result = await client.call_tool(tool_name, {"days": ["monday"]})
304
- # Client returns TextContent objects, so parse the JSON
305
- assert len(result) == 1
306
- assert result[0].type == "text"
307
- import json
308
-
309
- result_data = json.loads(result[0].text)
310
- assert result_data == {"selected": ["monday"]}
311
 
312
  # Multiple days
313
  result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
314
- assert len(result) == 1
315
- assert result[0].type == "text"
316
- result_data = json.loads(result[0].text)
317
- assert result_data == {"selected": ["monday", "tuesday"]}
318
 
319
 
320
  async def test_array_query_parameter_format(mock_client):
 
301
 
302
  # Single day
303
  result = await client.call_tool(tool_name, {"days": ["monday"]})
304
+ assert result.data == {"selected": ["monday"]}
 
 
 
 
 
 
305
 
306
  # Multiple days
307
  result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
308
+ assert result.data == {"selected": ["monday", "tuesday"]}
 
 
 
309
 
310
 
311
  async def test_array_query_parameter_format(mock_client):
tests/server/test_import_server.py CHANGED
@@ -224,7 +224,7 @@ async def test_call_imported_custom_named_tool():
224
 
225
  async with Client(main_app) as client:
226
  result = await client.call_tool("api_get_data", {"query": "test"})
227
- assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
228
 
229
 
230
  async def test_first_level_importing_with_custom_name():
@@ -278,7 +278,7 @@ async def test_call_nested_imported_tool():
278
 
279
  async with Client(main_app) as client:
280
  result = await client.call_tool("service_provider_compute", {"input": 21})
281
- assert result[0].text == "42" # type: ignore[attr-defined]
282
 
283
 
284
  async def test_import_with_proxy_tools():
@@ -302,7 +302,7 @@ async def test_import_with_proxy_tools():
302
 
303
  async with Client(main_app) as client:
304
  result = await client.call_tool("api_get_data", {"query": "test"})
305
- assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
306
 
307
 
308
  async def test_import_with_proxy_prompts():
@@ -443,7 +443,7 @@ async def test_import_with_no_prefix():
443
  async with Client(main_app) as client:
444
  # Test tool
445
  tool_result = await client.call_tool("sub_tool", {})
446
- assert tool_result[0].text == "Sub tool result" # type: ignore[attr-defined]
447
 
448
  # Test resource
449
  resource_result = await client.read_resource("data://config")
@@ -485,7 +485,7 @@ async def test_import_conflict_resolution_tools():
485
  assert tool_names.count("shared_tool") == 1 # Should only appear once
486
 
487
  result = await client.call_tool("shared_tool", {})
488
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
489
 
490
 
491
  async def test_import_conflict_resolution_resources():
@@ -604,4 +604,4 @@ async def test_import_conflict_resolution_with_prefix():
604
  assert tool_names.count("api_shared_tool") == 1 # Should only appear once
605
 
606
  result = await client.call_tool("api_shared_tool", {})
607
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
 
224
 
225
  async with Client(main_app) as client:
226
  result = await client.call_tool("api_get_data", {"query": "test"})
227
+ assert result.data == "Data for query: test"
228
 
229
 
230
  async def test_first_level_importing_with_custom_name():
 
278
 
279
  async with Client(main_app) as client:
280
  result = await client.call_tool("service_provider_compute", {"input": 21})
281
+ assert result.data == "42"
282
 
283
 
284
  async def test_import_with_proxy_tools():
 
302
 
303
  async with Client(main_app) as client:
304
  result = await client.call_tool("api_get_data", {"query": "test"})
305
+ assert result.data == "Data for query: test"
306
 
307
 
308
  async def test_import_with_proxy_prompts():
 
443
  async with Client(main_app) as client:
444
  # Test tool
445
  tool_result = await client.call_tool("sub_tool", {})
446
+ assert tool_result.data == "Sub tool result"
447
 
448
  # Test resource
449
  resource_result = await client.read_resource("data://config")
 
485
  assert tool_names.count("shared_tool") == 1 # Should only appear once
486
 
487
  result = await client.call_tool("shared_tool", {})
488
+ assert result.data == "Second app tool"
489
 
490
 
491
  async def test_import_conflict_resolution_resources():
 
604
  assert tool_names.count("api_shared_tool") == 1 # Should only appear once
605
 
606
  result = await client.call_tool("api_shared_tool", {})
607
+ assert result.data == "Second app tool"
tests/server/test_mount.py CHANGED
@@ -33,7 +33,7 @@ class TestBasicMount:
33
 
34
  async with Client(main_app) as client:
35
  result = await client.call_tool("sub_sub_tool", {})
36
- assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
37
 
38
  async def test_mount_with_custom_separator(self):
39
  """Test mounting with a custom tool separator (deprecated but still supported)."""
@@ -52,8 +52,9 @@ class TestBasicMount:
52
  assert "sub_greet" in tools
53
 
54
  # Call the tool
55
- result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
56
- assert result[0].text == "Hello, World!" # type: ignore[attr-defined]
 
57
 
58
  async def test_mount_invalid_resource_prefix(self):
59
  main_app = FastMCP("MainApp")
@@ -104,8 +105,9 @@ class TestBasicMount:
104
  assert "sub_tool" in tools
105
 
106
  # Call the tool to verify it works
107
- result = await main_app._mcp_call_tool("sub_tool", {})
108
- assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
 
109
 
110
  async def test_mount_tools_no_prefix(self):
111
  """Test mounting a server with tools without prefix."""
@@ -124,8 +126,9 @@ class TestBasicMount:
124
  assert "sub_tool" in tools
125
 
126
  # Test actual functionality
127
- tool_result = await main_app._mcp_call_tool("sub_tool", {})
128
- assert tool_result[0].text == "Sub tool result" # type: ignore[attr-defined]
 
129
 
130
  async def test_mount_resources_no_prefix(self):
131
  """Test mounting a server with resources without prefix."""
@@ -144,8 +147,9 @@ class TestBasicMount:
144
  assert "data://config" in resources
145
 
146
  # Test actual functionality
147
- resource_result = await main_app._mcp_read_resource("data://config")
148
- assert resource_result[0].content == "Sub resource data" # type: ignore[attr-defined]
 
149
 
150
  async def test_mount_resource_templates_no_prefix(self):
151
  """Test mounting a server with resource templates without prefix."""
@@ -164,8 +168,9 @@ class TestBasicMount:
164
  assert "users://{user_id}/info" in templates
165
 
166
  # Test actual functionality
167
- template_result = await main_app._mcp_read_resource("users://123/info")
168
- assert template_result[0].content == "Sub template for user 123" # type: ignore[attr-defined]
 
169
 
170
  async def test_mount_prompts_no_prefix(self):
171
  """Test mounting a server with prompts without prefix."""
@@ -184,8 +189,9 @@ class TestBasicMount:
184
  assert "sub_prompt" in prompts
185
 
186
  # Test actual functionality
187
- prompt_result = await main_app._mcp_get_prompt("sub_prompt", {})
188
- assert prompt_result.messages is not None
 
189
 
190
 
191
  class TestMultipleServerMount:
@@ -215,11 +221,11 @@ class TestMultipleServerMount:
215
  assert "news_get_headlines" in tools
216
 
217
  # Call tools from both mounted servers
218
- result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
219
- assert result1[0].text == "Weather forecast" # type: ignore[attr-defined]
220
-
221
- result2 = await main_app._mcp_call_tool("news_get_headlines", {})
222
- assert result2[0].text == "News headlines" # type: ignore[attr-defined]
223
 
224
  async def test_mount_same_prefix(self):
225
  """Test that mounting with the same prefix replaces the previous mount."""
@@ -292,7 +298,7 @@ class TestMultipleServerMount:
292
 
293
  # Test calling a tool
294
  result = await client.call_tool("working_working_tool", {})
295
- assert result[0].text == "Working tool" # type: ignore[attr-defined]
296
 
297
  # Test resources
298
  resources = await client.list_resources()
@@ -352,7 +358,7 @@ class TestPrefixConflictResolution:
352
 
353
  # Test that calling the tool uses the later server's implementation
354
  result = await client.call_tool("shared_tool", {})
355
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
356
 
357
  async def test_later_server_wins_tools_same_prefix(self):
358
  """Test that later mounted server wins for tools when same prefix is used."""
@@ -381,7 +387,7 @@ class TestPrefixConflictResolution:
381
 
382
  # Test that calling the tool uses the later server's implementation
383
  result = await client.call_tool("api_shared_tool", {})
384
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
385
 
386
  async def test_later_server_wins_resources_no_prefix(self):
387
  """Test that later mounted server wins for resources when no prefix is used."""
@@ -593,8 +599,9 @@ class TestDynamicChanges:
593
  assert "sub_dynamic_tool" in tools
594
 
595
  # Call the dynamically added tool
596
- result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
597
- assert result[0].text == "Added after mounting" # type: ignore[attr-defined]
 
598
 
599
  async def test_removing_tool_after_mounting(self):
600
  """Test that tools removed from mounted servers are no longer accessible."""
@@ -726,8 +733,9 @@ class TestPrompts:
726
  assert "assistant_greeting" in prompts
727
 
728
  # Render the prompt
729
- result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"})
730
- assert result.messages is not None
 
731
  # The message should contain our greeting text
732
 
733
  async def test_adding_prompt_after_mounting(self):
@@ -748,8 +756,9 @@ class TestPrompts:
748
  assert "assistant_farewell" in prompts
749
 
750
  # Render the prompt
751
- result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"})
752
- assert result.messages is not None
 
753
  # The message should contain our farewell text
754
 
755
 
@@ -779,8 +788,9 @@ class TestProxyServer:
779
  assert "proxy_get_data" in tools
780
 
781
  # Call the tool
782
- result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
783
- assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
784
 
785
  async def test_dynamically_adding_to_proxied_server(self):
786
  """Test that changes to the original server are reflected in the mounted proxy."""
@@ -806,8 +816,9 @@ class TestProxyServer:
806
  assert "proxy_dynamic_data" in tools
807
 
808
  # Call the tool
809
- result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
810
- assert result[0].text == "Dynamic data" # type: ignore[attr-defined]
 
811
 
812
  async def test_proxy_server_with_resources(self):
813
  """Test mounting a proxy server with resources."""
@@ -828,9 +839,10 @@ class TestProxyServer:
828
  main_app.mount(proxy_server, "proxy")
829
 
830
  # Resource should be accessible through main app
831
- result = await main_app._mcp_read_resource("config://proxy/settings")
832
- config = json.loads(result[0].content) # type: ignore[attr-defined]
833
- assert config["api_key"] == "12345"
 
834
 
835
  async def test_proxy_server_with_prompts(self):
836
  """Test mounting a proxy server with prompts."""
@@ -851,8 +863,9 @@ class TestProxyServer:
851
  main_app.mount(proxy_server, "proxy")
852
 
853
  # Prompt should be accessible through main app
854
- result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
855
- assert result.messages is not None
 
856
  # The message should contain our welcome text
857
 
858
 
 
33
 
34
  async with Client(main_app) as client:
35
  result = await client.call_tool("sub_sub_tool", {})
36
+ assert result.data == "This is from the sub app"
37
 
38
  async def test_mount_with_custom_separator(self):
39
  """Test mounting with a custom tool separator (deprecated but still supported)."""
 
52
  assert "sub_greet" in tools
53
 
54
  # Call the tool
55
+ async with Client(main_app) as client:
56
+ result = await client.call_tool("sub_greet", {"name": "World"})
57
+ assert result.data == "Hello, World!"
58
 
59
  async def test_mount_invalid_resource_prefix(self):
60
  main_app = FastMCP("MainApp")
 
105
  assert "sub_tool" in tools
106
 
107
  # Call the tool to verify it works
108
+ async with Client(main_app) as client:
109
+ result = await client.call_tool("sub_tool", {})
110
+ assert result.data == "This is from the sub app"
111
 
112
  async def test_mount_tools_no_prefix(self):
113
  """Test mounting a server with tools without prefix."""
 
126
  assert "sub_tool" in tools
127
 
128
  # Test actual functionality
129
+ async with Client(main_app) as client:
130
+ tool_result = await client.call_tool("sub_tool", {})
131
+ assert tool_result.data == "Sub tool result"
132
 
133
  async def test_mount_resources_no_prefix(self):
134
  """Test mounting a server with resources without prefix."""
 
147
  assert "data://config" in resources
148
 
149
  # Test actual functionality
150
+ async with Client(main_app) as client:
151
+ resource_result = await client.read_resource("data://config")
152
+ assert resource_result[0].text == "Sub resource data" # type: ignore[attr-defined]
153
 
154
  async def test_mount_resource_templates_no_prefix(self):
155
  """Test mounting a server with resource templates without prefix."""
 
168
  assert "users://{user_id}/info" in templates
169
 
170
  # Test actual functionality
171
+ async with Client(main_app) as client:
172
+ template_result = await client.read_resource("users://123/info")
173
+ assert template_result[0].text == "Sub template for user 123" # type: ignore[attr-defined]
174
 
175
  async def test_mount_prompts_no_prefix(self):
176
  """Test mounting a server with prompts without prefix."""
 
189
  assert "sub_prompt" in prompts
190
 
191
  # Test actual functionality
192
+ async with Client(main_app) as client:
193
+ prompt_result = await client.get_prompt("sub_prompt", {})
194
+ assert prompt_result.messages is not None
195
 
196
 
197
  class TestMultipleServerMount:
 
221
  assert "news_get_headlines" in tools
222
 
223
  # Call tools from both mounted servers
224
+ async with Client(main_app) as client:
225
+ result1 = await client.call_tool("weather_get_forecast", {})
226
+ assert result1.data == "Weather forecast"
227
+ result2 = await client.call_tool("news_get_headlines", {})
228
+ assert result2.data == "News headlines"
229
 
230
  async def test_mount_same_prefix(self):
231
  """Test that mounting with the same prefix replaces the previous mount."""
 
298
 
299
  # Test calling a tool
300
  result = await client.call_tool("working_working_tool", {})
301
+ assert result.data == "Working tool"
302
 
303
  # Test resources
304
  resources = await client.list_resources()
 
358
 
359
  # Test that calling the tool uses the later server's implementation
360
  result = await client.call_tool("shared_tool", {})
361
+ assert result.data == "Second app tool"
362
 
363
  async def test_later_server_wins_tools_same_prefix(self):
364
  """Test that later mounted server wins for tools when same prefix is used."""
 
387
 
388
  # Test that calling the tool uses the later server's implementation
389
  result = await client.call_tool("api_shared_tool", {})
390
+ assert result.data == "Second app tool"
391
 
392
  async def test_later_server_wins_resources_no_prefix(self):
393
  """Test that later mounted server wins for resources when no prefix is used."""
 
599
  assert "sub_dynamic_tool" in tools
600
 
601
  # Call the dynamically added tool
602
+ async with Client(main_app) as client:
603
+ result = await client.call_tool("sub_dynamic_tool", {})
604
+ assert result.data == "Added after mounting"
605
 
606
  async def test_removing_tool_after_mounting(self):
607
  """Test that tools removed from mounted servers are no longer accessible."""
 
733
  assert "assistant_greeting" in prompts
734
 
735
  # Render the prompt
736
+ async with Client(main_app) as client:
737
+ result = await client.get_prompt("assistant_greeting", {"name": "World"})
738
+ assert result.messages is not None
739
  # The message should contain our greeting text
740
 
741
  async def test_adding_prompt_after_mounting(self):
 
756
  assert "assistant_farewell" in prompts
757
 
758
  # Render the prompt
759
+ async with Client(main_app) as client:
760
+ result = await client.get_prompt("assistant_farewell", {"name": "World"})
761
+ assert result.messages is not None
762
  # The message should contain our farewell text
763
 
764
 
 
788
  assert "proxy_get_data" in tools
789
 
790
  # Call the tool
791
+ async with Client(main_app) as client:
792
+ result = await client.call_tool("proxy_get_data", {"query": "test"})
793
+ assert result.data == "Data for test"
794
 
795
  async def test_dynamically_adding_to_proxied_server(self):
796
  """Test that changes to the original server are reflected in the mounted proxy."""
 
816
  assert "proxy_dynamic_data" in tools
817
 
818
  # Call the tool
819
+ async with Client(main_app) as client:
820
+ result = await client.call_tool("proxy_dynamic_data", {})
821
+ assert result.data == "Dynamic data"
822
 
823
  async def test_proxy_server_with_resources(self):
824
  """Test mounting a proxy server with resources."""
 
839
  main_app.mount(proxy_server, "proxy")
840
 
841
  # Resource should be accessible through main app
842
+ async with Client(main_app) as client:
843
+ result = await client.read_resource("config://proxy/settings")
844
+ config = json.loads(result[0].text) # type: ignore[attr-defined]
845
+ assert config["api_key"] == "12345"
846
 
847
  async def test_proxy_server_with_prompts(self):
848
  """Test mounting a proxy server with prompts."""
 
863
  main_app.mount(proxy_server, "proxy")
864
 
865
  # Prompt should be accessible through main app
866
+ async with Client(main_app) as client:
867
+ result = await client.get_prompt("proxy_welcome", {"name": "World"})
868
+ assert result.messages is not None
869
  # The message should contain our welcome text
870
 
871
 
tests/server/test_proxy.py CHANGED
@@ -89,15 +89,17 @@ async def test_create_proxy(fastmcp_server):
89
  async def test_as_proxy_with_server(fastmcp_server):
90
  """FastMCP.as_proxy should accept a FastMCP instance."""
91
  proxy = FastMCP.as_proxy(fastmcp_server)
92
- result = await proxy._mcp_call_tool("greet", {"name": "Test"})
93
- assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
 
94
 
95
 
96
  async def test_as_proxy_with_transport(fastmcp_server):
97
  """FastMCP.as_proxy should accept a ClientTransport."""
98
  proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
99
- result = await proxy._mcp_call_tool("greet", {"name": "Test"})
100
- assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
 
101
 
102
 
103
  def test_as_proxy_with_url():
@@ -137,7 +139,7 @@ class TestTools:
137
  async def test_call_tool_calls_tool(self, proxy_server):
138
  async with Client(proxy_server) as client:
139
  proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
140
- assert proxy_result[0].text == "3" # type: ignore[attr-defined]
141
 
142
  async def test_error_tool_raises_error(self, proxy_server):
143
  with pytest.raises(ToolError, match="This is a test error"):
@@ -155,7 +157,7 @@ class TestTools:
155
 
156
  async with Client(proxy_server) as client:
157
  result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
158
- assert result[0].text == "Overwritten, Marvin! abc" # type: ignore[attr-defined]
159
 
160
  async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
161
  """
 
89
  async def test_as_proxy_with_server(fastmcp_server):
90
  """FastMCP.as_proxy should accept a FastMCP instance."""
91
  proxy = FastMCP.as_proxy(fastmcp_server)
92
+ async with Client(proxy) as client:
93
+ result = await client.call_tool("greet", {"name": "Test"})
94
+ assert result.data == "Hello, Test!"
95
 
96
 
97
  async def test_as_proxy_with_transport(fastmcp_server):
98
  """FastMCP.as_proxy should accept a ClientTransport."""
99
  proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
100
+ async with Client(proxy) as client:
101
+ result = await client.call_tool("greet", {"name": "Test"})
102
+ assert result.data == "Hello, Test!"
103
 
104
 
105
  def test_as_proxy_with_url():
 
139
  async def test_call_tool_calls_tool(self, proxy_server):
140
  async with Client(proxy_server) as client:
141
  proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
142
+ assert proxy_result.data == 3
143
 
144
  async def test_error_tool_raises_error(self, proxy_server):
145
  with pytest.raises(ToolError, match="This is a test error"):
 
157
 
158
  async with Client(proxy_server) as client:
159
  result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
160
+ assert result.data == "Overwritten, Marvin! abc"
161
 
162
  async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
163
  """
tests/server/test_server.py CHANGED
@@ -45,9 +45,7 @@ class TestCreateServer:
45
  assert "🎉" in tool.description
46
 
47
  result = await client.call_tool("hello_world", {})
48
- assert len(result) == 1
49
- content = result[0]
50
- assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined]
51
 
52
 
53
  class TestTools:
 
45
  assert "🎉" in tool.description
46
 
47
  result = await client.call_tool("hello_world", {})
48
+ assert result.data == "¡Hola, 世界! 👋"
 
 
49
 
50
 
51
  class TestTools:
tests/server/test_server_interactions.py CHANGED
@@ -2,11 +2,11 @@ import base64
2
  import datetime
3
  import json
4
  import uuid
 
5
  from enum import Enum
6
  from pathlib import Path
7
  from typing import Annotated, Literal
8
 
9
- import pydantic_core
10
  import pytest
11
  from mcp import McpError
12
  from mcp.types import (
@@ -17,7 +17,8 @@ from mcp.types import (
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
@@ -25,10 +26,26 @@ from fastmcp.exceptions import ToolError
25
  from fastmcp.prompts.prompt import Prompt, PromptMessage
26
  from fastmcp.resources import FileResource, ResourceTemplate
27
  from fastmcp.resources.resource import FunctionResource
28
- from fastmcp.tools.tool import Tool
29
  from fastmcp.utilities.types import Audio, File, Image
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  @pytest.fixture
33
  def tool_server():
34
  mcp = FastMCP()
@@ -72,7 +89,7 @@ def tool_server():
72
  ),
73
  ]
74
 
75
- @mcp.tool
76
  def mixed_list_fn(image_path: str) -> list:
77
  return [
78
  "text message",
@@ -81,7 +98,7 @@ def tool_server():
81
  TextContent(type="text", text="direct content"),
82
  ]
83
 
84
- @mcp.tool
85
  def mixed_audio_list_fn(audio_path: str) -> list:
86
  return [
87
  "text message",
@@ -90,7 +107,7 @@ def tool_server():
90
  TextContent(type="text", text="direct content"),
91
  ]
92
 
93
- @mcp.tool
94
  def mixed_file_list_fn(file_path: str) -> list:
95
  return [
96
  "text message",
@@ -117,26 +134,24 @@ class TestTools:
117
  async with Client(tool_server) as client:
118
  assert len(await client.list_tools()) == 11
119
 
120
- async def test_call_tool(self, tool_server: FastMCP):
121
  async with Client(tool_server) as client:
122
- result = await client.call_tool("add", {"x": 1, "y": 2})
123
- assert result[0].text == "3" # type: ignore[attr-defined]
 
124
 
125
- async def test_call_tool_as_client(self, tool_server: FastMCP):
126
  async with Client(tool_server) as client:
127
  result = await client.call_tool("add", {"x": 1, "y": 2})
128
- assert result[0].text == "3" # type: ignore[attr-defined]
 
 
129
 
130
  async def test_call_tool_error(self, tool_server: FastMCP):
131
  async with Client(tool_server) as client:
132
  with pytest.raises(Exception):
133
  await client.call_tool("error_tool", {})
134
 
135
- async def test_call_tool_error_as_client(self, tool_server: FastMCP):
136
- async with Client(tool_server) as client:
137
- with pytest.raises(Exception):
138
- await client.call_tool("error_tool", {})
139
-
140
  async def test_call_tool_error_as_client_raw(self):
141
  """Test raising and catching errors from a tool."""
142
  mcp = FastMCP()
@@ -154,13 +169,14 @@ class TestTools:
154
  async def test_tool_returns_list(self, tool_server: FastMCP):
155
  async with Client(tool_server) as client:
156
  result = await client.call_tool("list_tool", {})
157
- assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
 
158
 
159
  async def test_file_text_tool(self, tool_server: FastMCP):
160
  async with Client(tool_server) as client:
161
  result = await client.call_tool("file_text_tool", {})
162
- assert len(result) == 1
163
- embedded = result[0]
164
  assert isinstance(embedded, EmbeddedResource)
165
  resource = embedded.resource
166
  assert isinstance(resource, TextResourceContents)
@@ -222,7 +238,7 @@ class TestToolTags:
222
 
223
  async with Client(mcp) as client:
224
  result_1 = await client.call_tool("tool_1", {})
225
- assert result_1[0].text == "1" # type: ignore[attr-defined]
226
 
227
  with pytest.raises(ToolError, match="Unknown tool"):
228
  await client.call_tool("tool_2", {})
@@ -235,7 +251,7 @@ class TestToolTags:
235
  await client.call_tool("tool_1", {})
236
 
237
  result_2 = await client.call_tool("tool_2", {})
238
- assert result_2[0].text == "2" # type: ignore[attr-defined]
239
 
240
 
241
  class TestToolReturnTypes:
@@ -248,7 +264,7 @@ class TestToolReturnTypes:
248
 
249
  async with Client(mcp) as client:
250
  result = await client.call_tool("string_tool", {})
251
- assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
252
 
253
  async def test_bytes(self, tmp_path: Path):
254
  mcp = FastMCP()
@@ -259,7 +275,7 @@ class TestToolReturnTypes:
259
 
260
  async with Client(mcp) as client:
261
  result = await client.call_tool("bytes_tool", {})
262
- assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined]
263
 
264
  async def test_uuid(self):
265
  mcp = FastMCP()
@@ -272,7 +288,7 @@ class TestToolReturnTypes:
272
 
273
  async with Client(mcp) as client:
274
  result = await client.call_tool("uuid_tool", {})
275
- assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined]
276
 
277
  async def test_path(self):
278
  mcp = FastMCP()
@@ -285,7 +301,7 @@ class TestToolReturnTypes:
285
 
286
  async with Client(mcp) as client:
287
  result = await client.call_tool("path_tool", {})
288
- assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined]
289
 
290
  async def test_datetime(self):
291
  mcp = FastMCP()
@@ -298,7 +314,7 @@ class TestToolReturnTypes:
298
 
299
  async with Client(mcp) as client:
300
  result = await client.call_tool("datetime_tool", {})
301
- assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined]
302
 
303
  async def test_image(self, tmp_path: Path):
304
  mcp = FastMCP()
@@ -313,7 +329,8 @@ class TestToolReturnTypes:
313
 
314
  async with Client(mcp) as client:
315
  result = await client.call_tool("image_tool", {"path": str(image_path)})
316
- content = result[0]
 
317
  assert isinstance(content, ImageContent)
318
  assert content.type == "image"
319
  assert content.mimeType == "image/png"
@@ -334,7 +351,7 @@ class TestToolReturnTypes:
334
 
335
  async with Client(mcp) as client:
336
  result = await client.call_tool("audio_tool", {"path": str(audio_path)})
337
- content = result[0]
338
  assert isinstance(content, AudioContent)
339
  assert content.type == "audio"
340
  assert content.mimeType == "audio/wav"
@@ -355,7 +372,7 @@ class TestToolReturnTypes:
355
 
356
  async with Client(mcp) as client:
357
  result = await client.call_tool("file_tool", {"path": str(file_path)})
358
- content = result[0]
359
  assert isinstance(content, EmbeddedResource)
360
  assert content.type == "resource"
361
  resource = content.resource
@@ -371,10 +388,10 @@ class TestToolReturnTypes:
371
  async def test_tool_mixed_content(self, tool_server: FastMCP):
372
  async with Client(tool_server) as client:
373
  result = await client.call_tool("mixed_content_tool", {})
374
- assert len(result) == 3
375
- content1 = result[0]
376
- content2 = result[1]
377
- content3 = result[2]
378
  assert isinstance(content1, TextContent)
379
  assert content1.text == "Hello"
380
  assert isinstance(content2, ImageContent)
@@ -402,18 +419,18 @@ class TestToolReturnTypes:
402
  result = await client.call_tool(
403
  "mixed_list_fn", {"image_path": str(image_path)}
404
  )
405
- assert len(result) == 3
406
  # Check text conversion
407
- content1 = result[0]
408
  assert isinstance(content1, TextContent)
409
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
410
  # Check image conversion
411
- content2 = result[1]
412
  assert isinstance(content2, ImageContent)
413
  assert content2.mimeType == "image/png"
414
  assert base64.b64decode(content2.data) == b"test image data"
415
  # Check direct TextContent
416
- content3 = result[2]
417
  assert isinstance(content3, TextContent)
418
  assert content3.text == "direct content"
419
 
@@ -430,18 +447,18 @@ class TestToolReturnTypes:
430
  result = await client.call_tool(
431
  "mixed_audio_list_fn", {"audio_path": str(audio_path)}
432
  )
433
- assert len(result) == 3
434
  # Check text conversion
435
- content1 = result[0]
436
  assert isinstance(content1, TextContent)
437
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
438
  # Check audio conversion
439
- content2 = result[1]
440
  assert isinstance(content2, AudioContent)
441
  assert content2.mimeType == "audio/wav"
442
  assert base64.b64decode(content2.data) == b"test audio data"
443
  # Check direct TextContent
444
- content3 = result[2]
445
  assert isinstance(content3, TextContent)
446
  assert content3.text == "direct content"
447
 
@@ -458,13 +475,13 @@ class TestToolReturnTypes:
458
  result = await client.call_tool(
459
  "mixed_file_list_fn", {"file_path": str(file_path)}
460
  )
461
- assert len(result) == 3
462
  # Check text conversion
463
- content1 = result[0]
464
  assert isinstance(content1, TextContent)
465
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
466
  # Check file conversion
467
- content2 = result[1]
468
  assert isinstance(content2, EmbeddedResource)
469
  assert content2.type == "resource"
470
  resource = content2.resource
@@ -473,7 +490,7 @@ class TestToolReturnTypes:
473
  blob_data = getattr(resource, "blob")
474
  assert base64.b64decode(blob_data) == b"test file data"
475
  # Check direct TextContent
476
- content3 = result[2]
477
  assert isinstance(content3, TextContent)
478
  assert content3.text == "direct content"
479
 
@@ -540,9 +557,10 @@ class TestToolParameters:
540
  result = await client.call_tool(
541
  "process_image", {"image": b"fake png data"}
542
  )
543
- assert isinstance(result[0], ImageContent)
544
- assert result[0].mimeType == "image/png"
545
- assert result[0].data == base64.b64encode(b"fake png data").decode()
 
546
 
547
  async def test_tool_with_invalid_input(self):
548
  mcp = FastMCP()
@@ -660,7 +678,7 @@ class TestToolParameters:
660
 
661
  async with Client(mcp) as client:
662
  result = await client.call_tool("analyze", {"x": "a"})
663
- assert result[0].text == "a" # type: ignore[attr-defined]
664
 
665
  async def test_enum_type_validation_error(self):
666
  mcp = FastMCP()
@@ -695,7 +713,7 @@ class TestToolParameters:
695
 
696
  async with Client(mcp) as client:
697
  result = await client.call_tool("analyze", {"x": "red"})
698
- assert result[0].text == "red" # type: ignore[attr-defined]
699
 
700
  async def test_union_type_validation(self):
701
  mcp = FastMCP()
@@ -706,10 +724,10 @@ class TestToolParameters:
706
 
707
  async with Client(mcp) as client:
708
  result = await client.call_tool("analyze", {"x": 1})
709
- assert result[0].text == "1" # type: ignore[attr-defined]
710
 
711
  result = await client.call_tool("analyze", {"x": 1.0})
712
- assert result[0].text == "1.0" # type: ignore[attr-defined]
713
 
714
  with pytest.raises(
715
  ToolError,
@@ -730,7 +748,7 @@ class TestToolParameters:
730
 
731
  async with Client(mcp) as client:
732
  result = await client.call_tool("send_path", {"path": str(test_path)})
733
- assert result[0].text == str(test_path) # type: ignore[attr-defined]
734
 
735
  async def test_path_type_error(self):
736
  mcp = FastMCP()
@@ -757,7 +775,7 @@ class TestToolParameters:
757
 
758
  async with Client(mcp) as client:
759
  result = await client.call_tool("send_uuid", {"x": test_uuid})
760
- assert result[0].text == str(test_uuid) # type: ignore[attr-defined]
761
 
762
  async def test_uuid_type_error(self):
763
  mcp = FastMCP()
@@ -781,7 +799,7 @@ class TestToolParameters:
781
 
782
  async with Client(mcp) as client:
783
  result = await client.call_tool("send_datetime", {"x": dt})
784
- assert result[0].text == dt.isoformat() # type: ignore[attr-defined]
785
 
786
  async def test_datetime_type_parse_string(self):
787
  mcp = FastMCP()
@@ -794,7 +812,7 @@ class TestToolParameters:
794
  result = await client.call_tool(
795
  "send_datetime", {"x": "2021-01-01T00:00:00"}
796
  )
797
- assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined]
798
 
799
  async def test_datetime_type_error(self):
800
  mcp = FastMCP()
@@ -816,7 +834,7 @@ class TestToolParameters:
816
 
817
  async with Client(mcp) as client:
818
  result = await client.call_tool("send_date", {"x": datetime.date.today()})
819
- assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined]
820
 
821
  async def test_date_type_parse_string(self):
822
  mcp = FastMCP()
@@ -827,7 +845,7 @@ class TestToolParameters:
827
 
828
  async with Client(mcp) as client:
829
  result = await client.call_tool("send_date", {"x": "2021-01-01"})
830
- assert result[0].text == "2021-01-01" # type: ignore[attr-defined]
831
 
832
  async def test_timedelta_type(self):
833
  mcp = FastMCP()
@@ -840,7 +858,7 @@ class TestToolParameters:
840
  result = await client.call_tool(
841
  "send_timedelta", {"x": datetime.timedelta(days=1)}
842
  )
843
- assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined]
844
 
845
  async def test_timedelta_type_parse_int(self):
846
  """Test that invalid timedelta input raises validation error."""
@@ -860,8 +878,8 @@ class TestToolParameters:
860
 
861
 
862
  class TestToolOutputSchema:
863
- @pytest.mark.parametrize("annotation", [str, int, float, bool, list, dict, AnyUrl])
864
- async def test_output_schema(self, annotation):
865
  mcp = FastMCP()
866
 
867
  @mcp.tool
@@ -871,8 +889,62 @@ class TestToolOutputSchema:
871
  async with Client(mcp) as client:
872
  tools = await client.list_tools()
873
  assert len(tools) == 1
 
 
874
  # this line will fail until MCP adds output schemas!!
875
- assert tools[0].outputSchema == TypeAdapter(annotation).json_schema() # type: ignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
 
877
 
878
  class TestToolContextInjection:
@@ -903,9 +975,7 @@ class TestToolContextInjection:
903
 
904
  async with Client(mcp) as client:
905
  result = await client.call_tool("tool_with_context", {"x": 42})
906
- assert len(result) == 1
907
- content = result[0]
908
- assert content.text == "1" # type: ignore[attr-defined]
909
 
910
  async def test_async_context(self):
911
  """Test that context works in async functions."""
@@ -918,9 +988,7 @@ class TestToolContextInjection:
918
 
919
  async with Client(mcp) as client:
920
  result = await client.call_tool("async_tool", {"x": 42})
921
- assert len(result) == 1
922
- content = result[0]
923
- assert content.text == "Async request 1: 42" # type: ignore[attr-defined]
924
 
925
  async def test_optional_context(self):
926
  """Test that context is optional."""
@@ -932,9 +1000,7 @@ class TestToolContextInjection:
932
 
933
  async with Client(mcp) as client:
934
  result = await client.call_tool("no_context", {"x": 21})
935
- assert len(result) == 1
936
- content = result[0]
937
- assert content.text == "42" # type: ignore[attr-defined]
938
 
939
  async def test_context_resource_access(self):
940
  """Test that context can access resources."""
@@ -954,9 +1020,9 @@ class TestToolContextInjection:
954
 
955
  async with Client(mcp) as client:
956
  result = await client.call_tool("tool_with_resource", {})
957
- assert len(result) == 1
958
- content = result[0]
959
- assert "Read resource: resource data" in content.text # type: ignore[attr-defined]
960
 
961
  async def test_tool_decorator_with_tags(self):
962
  """Test that the tool decorator properly sets tags."""
@@ -984,7 +1050,7 @@ class TestToolContextInjection:
984
 
985
  async with Client(mcp) as client:
986
  result = await client.call_tool("MyTool", {"x": 2})
987
- assert result[0].text == "3" # type: ignore[attr-defined]
988
 
989
 
990
  class TestToolEnabled:
 
2
  import datetime
3
  import json
4
  import uuid
5
+ from dataclasses import dataclass
6
  from enum import Enum
7
  from pathlib import Path
8
  from typing import Annotated, Literal
9
 
 
10
  import pytest
11
  from mcp import McpError
12
  from mcp.types import (
 
17
  TextContent,
18
  TextResourceContents,
19
  )
20
+ from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
21
+ from typing_extensions import TypedDict
22
 
23
  from fastmcp import Client, Context, FastMCP
24
  from fastmcp.client.transports import FastMCPTransport
 
26
  from fastmcp.prompts.prompt import Prompt, PromptMessage
27
  from fastmcp.resources import FileResource, ResourceTemplate
28
  from fastmcp.resources.resource import FunctionResource
29
+ from fastmcp.tools.tool import Tool, ToolResult
30
  from fastmcp.utilities.types import Audio, File, Image
31
 
32
 
33
+ class PersonTypedDict(TypedDict):
34
+ name: str
35
+ age: int
36
+
37
+
38
+ class PersonModel(BaseModel):
39
+ name: str
40
+ age: int
41
+
42
+
43
+ @dataclass
44
+ class PersonDataclass:
45
+ name: str
46
+ age: int
47
+
48
+
49
  @pytest.fixture
50
  def tool_server():
51
  mcp = FastMCP()
 
89
  ),
90
  ]
91
 
92
+ @mcp.tool(output_schema=None)
93
  def mixed_list_fn(image_path: str) -> list:
94
  return [
95
  "text message",
 
98
  TextContent(type="text", text="direct content"),
99
  ]
100
 
101
+ @mcp.tool(output_schema=None)
102
  def mixed_audio_list_fn(audio_path: str) -> list:
103
  return [
104
  "text message",
 
107
  TextContent(type="text", text="direct content"),
108
  ]
109
 
110
+ @mcp.tool(output_schema=None)
111
  def mixed_file_list_fn(file_path: str) -> list:
112
  return [
113
  "text message",
 
134
  async with Client(tool_server) as client:
135
  assert len(await client.list_tools()) == 11
136
 
137
+ async def test_call_tool_mcp(self, tool_server: FastMCP):
138
  async with Client(tool_server) as client:
139
+ result = await client.call_tool_mcp("add", {"x": 1, "y": 2})
140
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
141
+ assert result.structuredContent == {"result": 3}
142
 
143
+ async def test_call_tool(self, tool_server: FastMCP):
144
  async with Client(tool_server) as client:
145
  result = await client.call_tool("add", {"x": 1, "y": 2})
146
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
147
+ assert result.structured_content == {"result": 3}
148
+ assert result.data == 3
149
 
150
  async def test_call_tool_error(self, tool_server: FastMCP):
151
  async with Client(tool_server) as client:
152
  with pytest.raises(Exception):
153
  await client.call_tool("error_tool", {})
154
 
 
 
 
 
 
155
  async def test_call_tool_error_as_client_raw(self):
156
  """Test raising and catching errors from a tool."""
157
  mcp = FastMCP()
 
169
  async def test_tool_returns_list(self, tool_server: FastMCP):
170
  async with Client(tool_server) as client:
171
  result = await client.call_tool("list_tool", {})
172
+ assert result.content[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
173
+ assert result.data == ["x", 2]
174
 
175
  async def test_file_text_tool(self, tool_server: FastMCP):
176
  async with Client(tool_server) as client:
177
  result = await client.call_tool("file_text_tool", {})
178
+ assert len(result.content) == 1
179
+ embedded = result.content[0]
180
  assert isinstance(embedded, EmbeddedResource)
181
  resource = embedded.resource
182
  assert isinstance(resource, TextResourceContents)
 
238
 
239
  async with Client(mcp) as client:
240
  result_1 = await client.call_tool("tool_1", {})
241
+ assert result_1.data == 1
242
 
243
  with pytest.raises(ToolError, match="Unknown tool"):
244
  await client.call_tool("tool_2", {})
 
251
  await client.call_tool("tool_1", {})
252
 
253
  result_2 = await client.call_tool("tool_2", {})
254
+ assert result_2.data == 2
255
 
256
 
257
  class TestToolReturnTypes:
 
264
 
265
  async with Client(mcp) as client:
266
  result = await client.call_tool("string_tool", {})
267
+ assert result.data == "Hello, world!"
268
 
269
  async def test_bytes(self, tmp_path: Path):
270
  mcp = FastMCP()
 
275
 
276
  async with Client(mcp) as client:
277
  result = await client.call_tool("bytes_tool", {})
278
+ assert result.data == "Hello, world!"
279
 
280
  async def test_uuid(self):
281
  mcp = FastMCP()
 
288
 
289
  async with Client(mcp) as client:
290
  result = await client.call_tool("uuid_tool", {})
291
+ assert result.data == str(test_uuid)
292
 
293
  async def test_path(self):
294
  mcp = FastMCP()
 
301
 
302
  async with Client(mcp) as client:
303
  result = await client.call_tool("path_tool", {})
304
+ assert result.data == str(test_path)
305
 
306
  async def test_datetime(self):
307
  mcp = FastMCP()
 
314
 
315
  async with Client(mcp) as client:
316
  result = await client.call_tool("datetime_tool", {})
317
+ assert result.data == dt
318
 
319
  async def test_image(self, tmp_path: Path):
320
  mcp = FastMCP()
 
329
 
330
  async with Client(mcp) as client:
331
  result = await client.call_tool("image_tool", {"path": str(image_path)})
332
+ assert result.structured_content is None
333
+ content = result.content[0]
334
  assert isinstance(content, ImageContent)
335
  assert content.type == "image"
336
  assert content.mimeType == "image/png"
 
351
 
352
  async with Client(mcp) as client:
353
  result = await client.call_tool("audio_tool", {"path": str(audio_path)})
354
+ content = result.content[0]
355
  assert isinstance(content, AudioContent)
356
  assert content.type == "audio"
357
  assert content.mimeType == "audio/wav"
 
372
 
373
  async with Client(mcp) as client:
374
  result = await client.call_tool("file_tool", {"path": str(file_path)})
375
+ content = result.content[0]
376
  assert isinstance(content, EmbeddedResource)
377
  assert content.type == "resource"
378
  resource = content.resource
 
388
  async def test_tool_mixed_content(self, tool_server: FastMCP):
389
  async with Client(tool_server) as client:
390
  result = await client.call_tool("mixed_content_tool", {})
391
+ assert len(result.content) == 3
392
+ content1 = result.content[0]
393
+ content2 = result.content[1]
394
+ content3 = result.content[2]
395
  assert isinstance(content1, TextContent)
396
  assert content1.text == "Hello"
397
  assert isinstance(content2, ImageContent)
 
419
  result = await client.call_tool(
420
  "mixed_list_fn", {"image_path": str(image_path)}
421
  )
422
+ assert len(result.content) == 3
423
  # Check text conversion
424
+ content1 = result.content[0]
425
  assert isinstance(content1, TextContent)
426
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
427
  # Check image conversion
428
+ content2 = result.content[1]
429
  assert isinstance(content2, ImageContent)
430
  assert content2.mimeType == "image/png"
431
  assert base64.b64decode(content2.data) == b"test image data"
432
  # Check direct TextContent
433
+ content3 = result.content[2]
434
  assert isinstance(content3, TextContent)
435
  assert content3.text == "direct content"
436
 
 
447
  result = await client.call_tool(
448
  "mixed_audio_list_fn", {"audio_path": str(audio_path)}
449
  )
450
+ assert len(result.content) == 3
451
  # Check text conversion
452
+ content1 = result.content[0]
453
  assert isinstance(content1, TextContent)
454
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
455
  # Check audio conversion
456
+ content2 = result.content[1]
457
  assert isinstance(content2, AudioContent)
458
  assert content2.mimeType == "audio/wav"
459
  assert base64.b64decode(content2.data) == b"test audio data"
460
  # Check direct TextContent
461
+ content3 = result.content[2]
462
  assert isinstance(content3, TextContent)
463
  assert content3.text == "direct content"
464
 
 
475
  result = await client.call_tool(
476
  "mixed_file_list_fn", {"file_path": str(file_path)}
477
  )
478
+ assert len(result.content) == 3
479
  # Check text conversion
480
+ content1 = result.content[0]
481
  assert isinstance(content1, TextContent)
482
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
483
  # Check file conversion
484
+ content2 = result.content[1]
485
  assert isinstance(content2, EmbeddedResource)
486
  assert content2.type == "resource"
487
  resource = content2.resource
 
490
  blob_data = getattr(resource, "blob")
491
  assert base64.b64decode(blob_data) == b"test file data"
492
  # Check direct TextContent
493
+ content3 = result.content[2]
494
  assert isinstance(content3, TextContent)
495
  assert content3.text == "direct content"
496
 
 
557
  result = await client.call_tool(
558
  "process_image", {"image": b"fake png data"}
559
  )
560
+ assert result.structured_content is None
561
+ assert isinstance(result.content[0], ImageContent)
562
+ assert result.content[0].mimeType == "image/png"
563
+ assert result.content[0].data == base64.b64encode(b"fake png data").decode()
564
 
565
  async def test_tool_with_invalid_input(self):
566
  mcp = FastMCP()
 
678
 
679
  async with Client(mcp) as client:
680
  result = await client.call_tool("analyze", {"x": "a"})
681
+ assert result.data == "a"
682
 
683
  async def test_enum_type_validation_error(self):
684
  mcp = FastMCP()
 
713
 
714
  async with Client(mcp) as client:
715
  result = await client.call_tool("analyze", {"x": "red"})
716
+ assert result.data == "red"
717
 
718
  async def test_union_type_validation(self):
719
  mcp = FastMCP()
 
724
 
725
  async with Client(mcp) as client:
726
  result = await client.call_tool("analyze", {"x": 1})
727
+ assert result.data == "1"
728
 
729
  result = await client.call_tool("analyze", {"x": 1.0})
730
+ assert result.data == "1.0"
731
 
732
  with pytest.raises(
733
  ToolError,
 
748
 
749
  async with Client(mcp) as client:
750
  result = await client.call_tool("send_path", {"path": str(test_path)})
751
+ assert result.data == str(test_path)
752
 
753
  async def test_path_type_error(self):
754
  mcp = FastMCP()
 
775
 
776
  async with Client(mcp) as client:
777
  result = await client.call_tool("send_uuid", {"x": test_uuid})
778
+ assert result.data == str(test_uuid)
779
 
780
  async def test_uuid_type_error(self):
781
  mcp = FastMCP()
 
799
 
800
  async with Client(mcp) as client:
801
  result = await client.call_tool("send_datetime", {"x": dt})
802
+ assert result.data == dt.isoformat()
803
 
804
  async def test_datetime_type_parse_string(self):
805
  mcp = FastMCP()
 
812
  result = await client.call_tool(
813
  "send_datetime", {"x": "2021-01-01T00:00:00"}
814
  )
815
+ assert result.data == "2021-01-01T00:00:00"
816
 
817
  async def test_datetime_type_error(self):
818
  mcp = FastMCP()
 
834
 
835
  async with Client(mcp) as client:
836
  result = await client.call_tool("send_date", {"x": datetime.date.today()})
837
+ assert result.data == datetime.date.today().isoformat()
838
 
839
  async def test_date_type_parse_string(self):
840
  mcp = FastMCP()
 
845
 
846
  async with Client(mcp) as client:
847
  result = await client.call_tool("send_date", {"x": "2021-01-01"})
848
+ assert result.data == "2021-01-01"
849
 
850
  async def test_timedelta_type(self):
851
  mcp = FastMCP()
 
858
  result = await client.call_tool(
859
  "send_timedelta", {"x": datetime.timedelta(days=1)}
860
  )
861
+ assert result.data == "1 day, 0:00:00"
862
 
863
  async def test_timedelta_type_parse_int(self):
864
  """Test that invalid timedelta input raises validation error."""
 
878
 
879
 
880
  class TestToolOutputSchema:
881
+ @pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])
882
+ async def test_simple_output_schema(self, annotation):
883
  mcp = FastMCP()
884
 
885
  @mcp.tool
 
889
  async with Client(mcp) as client:
890
  tools = await client.list_tools()
891
  assert len(tools) == 1
892
+
893
+ type_schema = TypeAdapter(annotation).json_schema()
894
  # this line will fail until MCP adds output schemas!!
895
+ assert tools[0].outputSchema == {
896
+ "type": "object",
897
+ "properties": {"result": type_schema},
898
+ "x-fastmcp-wrap-result": True,
899
+ }
900
+
901
+ @pytest.mark.parametrize(
902
+ "annotation",
903
+ [dict[str, int | str], PersonTypedDict, PersonModel, PersonDataclass],
904
+ )
905
+ async def test_structured_output_schema(self, annotation):
906
+ mcp = FastMCP()
907
+
908
+ @mcp.tool
909
+ def f() -> annotation:
910
+ return {"name": "John", "age": 30}
911
+
912
+ async with Client(mcp) as client:
913
+ tools = await client.list_tools()
914
+
915
+ type_schema = TypeAdapter(annotation).json_schema()
916
+ assert len(tools) == 1
917
+ assert tools[0].outputSchema == type_schema
918
+
919
+ async def test_disabled_output_schema_no_structured_content(self):
920
+ mcp = FastMCP()
921
+
922
+ @mcp.tool(output_schema=None)
923
+ def f() -> dict[str, str]:
924
+ return {"message": "Hello, world!"}
925
+
926
+ async with Client(mcp) as client:
927
+ result = await client.call_tool("f", {})
928
+ assert json.loads(result.content[0].text) == {"message": "Hello, world!"} # type: ignore[attr-defined]
929
+ assert result.structured_content is None
930
+ assert result.data is None
931
+
932
+ async def test_manual_structured_content(self):
933
+ mcp = FastMCP()
934
+
935
+ @mcp.tool
936
+ def f() -> ToolResult:
937
+ return ToolResult(
938
+ content="Hello, world!", structured_content={"message": "Hello, world!"}
939
+ )
940
+
941
+ assert f.output_schema is None
942
+
943
+ async with Client(mcp) as client:
944
+ result = await client.call_tool("f", {})
945
+ assert result.content[0].text == "Hello, world!" # type: ignore[attr-defined]
946
+ assert result.structured_content == {"message": "Hello, world!"}
947
+ assert result.data == {"message": "Hello, world!"}
948
 
949
 
950
  class TestToolContextInjection:
 
975
 
976
  async with Client(mcp) as client:
977
  result = await client.call_tool("tool_with_context", {"x": 42})
978
+ assert result.data == "1"
 
 
979
 
980
  async def test_async_context(self):
981
  """Test that context works in async functions."""
 
988
 
989
  async with Client(mcp) as client:
990
  result = await client.call_tool("async_tool", {"x": 42})
991
+ assert result.data == "Async request 1: 42"
 
 
992
 
993
  async def test_optional_context(self):
994
  """Test that context is optional."""
 
1000
 
1001
  async with Client(mcp) as client:
1002
  result = await client.call_tool("no_context", {"x": 21})
1003
+ assert result.data == 42
 
 
1004
 
1005
  async def test_context_resource_access(self):
1006
  """Test that context can access resources."""
 
1020
 
1021
  async with Client(mcp) as client:
1022
  result = await client.call_tool("tool_with_resource", {})
1023
+ assert (
1024
+ result.data == "Read resource: resource data with mime type text/plain"
1025
+ )
1026
 
1027
  async def test_tool_decorator_with_tags(self):
1028
  """Test that the tool decorator properly sets tags."""
 
1050
 
1051
  async with Client(mcp) as client:
1052
  result = await client.call_tool("MyTool", {"x": 2})
1053
+ assert result.data == 3
1054
 
1055
 
1056
  class TestToolEnabled:
tests/server/test_tool_annotations.py CHANGED
@@ -218,8 +218,4 @@ async def test_tool_functionality_with_annotations():
218
  result = await client.call_tool(
219
  "create_item", {"name": "test_item", "value": 42}
220
  )
221
- assert len(result) == 1
222
-
223
- # The result should contain the expected JSON
224
- assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined]
225
- assert '"value": 42' in result[0].text # type: ignore[attr-defined]
 
218
  result = await client.call_tool(
219
  "create_item", {"name": "test_item", "value": 42}
220
  )
221
+ assert result.data == {"name": "test_item", "value": 42}
 
 
 
 
tests/server/test_tool_exclude_args.py CHANGED
@@ -1,7 +1,6 @@
1
  from typing import Any
2
 
3
  import pytest
4
- from mcp.types import TextContent
5
 
6
  from fastmcp import Client, FastMCP
7
  from fastmcp.tools.tool import Tool
@@ -92,9 +91,4 @@ async def test_tool_functionality_with_exclude_args():
92
  result = await client.call_tool(
93
  "create_item", {"name": "test_item", "value": 42}
94
  )
95
- assert len(result) == 1
96
- assert isinstance(result[0], TextContent)
97
-
98
- # The result should contain the expected JSON
99
- assert '"name": "test_item"' in result[0].text
100
- assert '"value": 42' in result[0].text
 
1
  from typing import Any
2
 
3
  import pytest
 
4
 
5
  from fastmcp import Client, FastMCP
6
  from fastmcp.tools.tool import Tool
 
91
  result = await client.call_tool(
92
  "create_item", {"name": "test_item", "value": 42}
93
  )
94
+ assert result.data == {"name": "test_item", "value": 42}
 
 
 
 
 
tests/test_examples.py CHANGED
@@ -10,9 +10,9 @@ async def test_simple_echo():
10
  from examples.simple_echo import mcp
11
 
12
  async with Client(mcp) as client:
13
- result = await client.call_tool("echo", {"text": "hello"})
14
- assert len(result) == 1
15
- assert result[0].text == "hello" # type: ignore[attr-defined]
16
 
17
 
18
  async def test_complex_inputs():
@@ -21,11 +21,11 @@ async def test_complex_inputs():
21
 
22
  async with Client(mcp) as client:
23
  tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
24
- result = await client.call_tool(
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
- assert len(result) == 1
28
- assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
29
 
30
 
31
  async def test_desktop(monkeypatch):
@@ -34,9 +34,9 @@ async def test_desktop(monkeypatch):
34
 
35
  async with Client(mcp) as client:
36
  # Test the add function
37
- result = await client.call_tool("add", {"a": 1, "b": 2})
38
- assert len(result) == 1
39
- assert result[0].text == "3" # type: ignore[attr-defined]
40
 
41
  async with Client(mcp) as client:
42
  result = await client.read_resource(AnyUrl("greeting://rooter12"))
@@ -49,9 +49,9 @@ async def test_echo():
49
  from examples.echo import mcp
50
 
51
  async with Client(mcp) as client:
52
- result = await client.call_tool("echo_tool", {"text": "hello"})
53
- assert len(result) == 1
54
- assert result[0].text == "hello" # type: ignore[attr-defined]
55
 
56
  async with Client(mcp) as client:
57
  result = await client.read_resource(AnyUrl("echo://static"))
 
10
  from examples.simple_echo import mcp
11
 
12
  async with Client(mcp) as client:
13
+ result = await client.call_tool_mcp("echo", {"text": "hello"})
14
+ assert len(result.content) == 1
15
+ assert result.content[0].text == "hello" # type: ignore[attr-defined]
16
 
17
 
18
  async def test_complex_inputs():
 
21
 
22
  async with Client(mcp) as client:
23
  tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
24
+ result = await client.call_tool_mcp(
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
+ assert len(result.content) == 1
28
+ assert result.content[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
29
 
30
 
31
  async def test_desktop(monkeypatch):
 
34
 
35
  async with Client(mcp) as client:
36
  # Test the add function
37
+ result = await client.call_tool_mcp("add", {"a": 1, "b": 2})
38
+ assert len(result.content) == 1
39
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
40
 
41
  async with Client(mcp) as client:
42
  result = await client.read_resource(AnyUrl("greeting://rooter12"))
 
49
  from examples.echo import mcp
50
 
51
  async with Client(mcp) as client:
52
+ result = await client.call_tool_mcp("echo_tool", {"text": "hello"})
53
+ assert len(result.content) == 1
54
+ assert result.content[0].text == "hello" # type: ignore[attr-defined]
55
 
56
  async with Client(mcp) as client:
57
  result = await client.read_resource(AnyUrl("echo://static"))
tests/tools/test_tool.py CHANGED
@@ -31,7 +31,15 @@ class TestToolFromFunction:
31
  assert len(tool.parameters["properties"]) == 2
32
  assert tool.parameters["properties"]["a"]["type"] == "integer"
33
  assert tool.parameters["properties"]["b"]["type"] == "integer"
34
- assert tool.output_schema == {"type": "integer"}
 
 
 
 
 
 
 
 
35
 
36
  async def test_async_function(self):
37
  """Test registering and running an async function."""
@@ -103,7 +111,7 @@ class TestToolFromFunction:
103
 
104
  result = await tool.run({"data": "test.png"})
105
  assert tool.parameters["properties"]["data"]["type"] == "string"
106
- assert isinstance(result[0], ImageContent)
107
 
108
  async def test_tool_with_audio_return(self):
109
  def audio_tool(data: bytes) -> Audio:
@@ -113,7 +121,7 @@ class TestToolFromFunction:
113
 
114
  result = await tool.run({"data": "test.wav"})
115
  assert tool.parameters["properties"]["data"]["type"] == "string"
116
- assert isinstance(result[0], AudioContent)
117
 
118
  async def test_tool_with_file_return(self):
119
  def file_tool(data: bytes) -> File:
@@ -123,11 +131,11 @@ class TestToolFromFunction:
123
 
124
  result = await tool.run({"data": "test.bin"})
125
  assert tool.parameters["properties"]["data"]["type"] == "string"
126
- assert len(result) == 1
127
- assert isinstance(result[0], EmbeddedResource)
128
- assert result[0].type == "resource"
129
- assert hasattr(result[0], "resource")
130
- resource = result[0].resource
131
  assert resource.mimeType == "application/octet-stream"
132
 
133
  def test_non_callable_fn(self):
@@ -239,8 +247,11 @@ class TestToolFromFunction:
239
  tool = Tool.from_function(process_list, serializer=custom_serializer)
240
 
241
  result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
242
- assert isinstance(result[0], TextContent)
243
- assert result[0].text == "Custom serializer: 15"
 
 
 
244
 
245
 
246
  class TestToolFromFunctionOutputSchema:
@@ -273,7 +284,31 @@ class TestToolFromFunctionOutputSchema:
273
  return 1
274
 
275
  tool = Tool.from_function(func)
276
- assert tool.output_schema == TypeAdapter(annotation).json_schema()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  @pytest.mark.parametrize(
279
  "annotation",
@@ -289,7 +324,10 @@ class TestToolFromFunctionOutputSchema:
289
  return 1
290
 
291
  tool = Tool.from_function(func)
292
- assert tool.output_schema == TypeAdapter(annotation).json_schema()
 
 
 
293
 
294
  @pytest.mark.parametrize(
295
  "annotation, expected",
@@ -307,7 +345,8 @@ class TestToolFromFunctionOutputSchema:
307
  return 1
308
 
309
  tool = Tool.from_function(func)
310
- assert tool.output_schema == TypeAdapter(expected).json_schema()
 
311
 
312
  async def test_dataclass_return_annotation(self):
313
  @dataclass
 
31
  assert len(tool.parameters["properties"]) == 2
32
  assert tool.parameters["properties"]["a"]["type"] == "integer"
33
  assert tool.parameters["properties"]["b"]["type"] == "integer"
34
+ # With primitive wrapping, int return type becomes object with value property
35
+ expected_schema = {
36
+ "type": "object",
37
+ "properties": {"value": {"title": "Value", "type": "integer"}},
38
+ "required": ["value"],
39
+ "title": "Result",
40
+ "x-fastmcp-wrap-result": True,
41
+ }
42
+ assert tool.output_schema == expected_schema
43
 
44
  async def test_async_function(self):
45
  """Test registering and running an async function."""
 
111
 
112
  result = await tool.run({"data": "test.png"})
113
  assert tool.parameters["properties"]["data"]["type"] == "string"
114
+ assert isinstance(result.content[0], ImageContent)
115
 
116
  async def test_tool_with_audio_return(self):
117
  def audio_tool(data: bytes) -> Audio:
 
121
 
122
  result = await tool.run({"data": "test.wav"})
123
  assert tool.parameters["properties"]["data"]["type"] == "string"
124
+ assert isinstance(result.content[0], AudioContent)
125
 
126
  async def test_tool_with_file_return(self):
127
  def file_tool(data: bytes) -> File:
 
131
 
132
  result = await tool.run({"data": "test.bin"})
133
  assert tool.parameters["properties"]["data"]["type"] == "string"
134
+ assert len(result.content) == 1
135
+ assert isinstance(result.content[0], EmbeddedResource)
136
+ assert result.content[0].type == "resource"
137
+ assert hasattr(result.content[0], "resource")
138
+ resource = result.content[0].resource
139
  assert resource.mimeType == "application/octet-stream"
140
 
141
  def test_non_callable_fn(self):
 
247
  tool = Tool.from_function(process_list, serializer=custom_serializer)
248
 
249
  result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
250
+ # Custom serializer affects unstructured content
251
+ assert isinstance(result.content[0], TextContent)
252
+ assert result.content[0].text == "Custom serializer: 15"
253
+ # Structured output should have the raw value
254
+ assert result.structured_content == {"value": 15}
255
 
256
 
257
  class TestToolFromFunctionOutputSchema:
 
284
  return 1
285
 
286
  tool = Tool.from_function(func)
287
+
288
+ base_schema = TypeAdapter(annotation).json_schema()
289
+
290
+ # Only pure primitives (just type + optional title) get wrapped
291
+ primitive_types = {"string", "number", "integer", "boolean", "null"}
292
+ schema_type = base_schema.get("type")
293
+ is_pure_primitive = (
294
+ schema_type in primitive_types
295
+ and len(base_schema) <= 2 # Only 'type' and optionally 'title'
296
+ and all(key in {"type", "title"} for key in base_schema.keys())
297
+ )
298
+
299
+ if is_pure_primitive:
300
+ # Pure primitives get wrapped
301
+ expected_schema = {
302
+ "type": "object",
303
+ "properties": {"value": base_schema | {"title": "Value"}},
304
+ "required": ["value"],
305
+ "title": "Result",
306
+ "x-fastmcp-wrap-result": True,
307
+ }
308
+ assert tool.output_schema == expected_schema
309
+ else:
310
+ # Complex types (objects, unions, constrained types) remain unwrapped
311
+ assert tool.output_schema == base_schema
312
 
313
  @pytest.mark.parametrize(
314
  "annotation",
 
324
  return 1
325
 
326
  tool = Tool.from_function(func)
327
+ base_schema = TypeAdapter(annotation).json_schema()
328
+
329
+ # Complex types with constraints are not wrapped - they remain as-is
330
+ assert tool.output_schema == base_schema
331
 
332
  @pytest.mark.parametrize(
333
  "annotation, expected",
 
345
  return 1
346
 
347
  tool = Tool.from_function(func)
348
+ # Image, Audio, File types don't generate output schemas since they're converted to content directly
349
+ assert tool.output_schema is None
350
 
351
  async def test_dataclass_return_annotation(self):
352
  @dataclass
tests/tools/test_tool_manager.py CHANGED
@@ -125,7 +125,8 @@ class TestAddTools:
125
  tool = await manager.get_tool("image_tool")
126
  result = await tool.run({"data": "test.png"})
127
  assert tool.parameters["properties"]["data"]["type"] == "string"
128
- assert isinstance(result[0], ImageContent)
 
129
 
130
  def test_add_noncallable_tool(self):
131
  manager = ToolManager()
@@ -353,7 +354,8 @@ class TestCallTools:
353
  manager.add_tool(tool)
354
  result = await manager.call_tool("add", {"a": 1, "b": 2})
355
 
356
- assert result[0].text == "3" # type: ignore[attr-defined]
 
357
 
358
  async def test_call_async_tool(self):
359
  async def double(n: int) -> int:
@@ -364,7 +366,8 @@ class TestCallTools:
364
  tool = Tool.from_function(double)
365
  manager.add_tool(tool)
366
  result = await manager.call_tool("double", {"n": 5})
367
- assert result[0].text == "10" # type: ignore[attr-defined]
 
368
 
369
  async def test_call_tool_callable_object(self):
370
  class Adder:
@@ -378,7 +381,8 @@ class TestCallTools:
378
  tool = Tool.from_function(Adder())
379
  manager.add_tool(tool)
380
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
381
- assert result[0].text == "3" # type: ignore[attr-defined]
 
382
 
383
  async def test_call_tool_callable_object_async(self):
384
  class Adder:
@@ -392,7 +396,8 @@ class TestCallTools:
392
  tool = Tool.from_function(Adder())
393
  manager.add_tool(tool)
394
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
395
- assert result[0].text == "3" # type: ignore[attr-defined]
 
396
 
397
  async def test_call_tool_with_default_args(self):
398
  def add(a: int, b: int = 1) -> int:
@@ -404,7 +409,8 @@ class TestCallTools:
404
  manager.add_tool(tool)
405
  result = await manager.call_tool("add", {"a": 1})
406
 
407
- assert result[0].text == "2" # type: ignore[attr-defined]
 
408
 
409
  async def test_call_tool_with_missing_args(self):
410
  def add(a: int, b: int) -> int:
@@ -431,7 +437,8 @@ class TestCallTools:
431
  manager.add_tool(tool)
432
 
433
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
434
- assert result[0].text == "6" # type: ignore[attr-defined]
 
435
 
436
  async def test_call_tool_with_list_str_or_str_input(self):
437
  def concat_strs(vals: list[str] | str) -> str:
@@ -443,10 +450,12 @@ class TestCallTools:
443
 
444
  # Try both with plain python object and with JSON list
445
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
446
- assert result[0].text == "abc" # type: ignore[attr-defined]
 
447
 
448
  result = await manager.call_tool("concat_strs", {"vals": "a"})
449
- assert result[0].text == "a" # type: ignore[attr-defined]
 
450
 
451
  async def test_call_tool_with_complex_model(self):
452
  class MyShrimpTank(BaseModel):
@@ -477,7 +486,8 @@ class TestCallTools:
477
  },
478
  )
479
 
480
- assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
 
481
 
482
  async def test_call_tool_with_custom_serializer(self):
483
  """Test that a custom serializer provided to FastMCP is used by tools."""
@@ -496,7 +506,8 @@ class TestCallTools:
496
  return {"key": "value", "number": 123}
497
 
498
  result = await manager.call_tool("get_data", {})
499
- assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
 
500
 
501
  async def test_call_tool_with_list_result_custom_serializer(self):
502
  """Test that a custom serializer provided to FastMCP is used by tools that return lists."""
@@ -518,9 +529,15 @@ class TestCallTools:
518
 
519
  result = await manager.call_tool("get_data", {})
520
  assert (
521
- result[0].text # type: ignore[attr-defined]
522
  == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
523
  )
 
 
 
 
 
 
524
 
525
  async def test_custom_serializer_fallback_on_error(self):
526
  """Test that a broken custom serializer gracefully falls back."""
@@ -538,7 +555,11 @@ class TestCallTools:
538
  return uuid_result
539
 
540
  result = await manager.call_tool("get_data", {})
541
- assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined]
 
 
 
 
542
 
543
 
544
  class TestToolSchema:
@@ -608,7 +629,8 @@ class TestContextHandling:
608
 
609
  async with context:
610
  result = await manager.call_tool("tool_with_context", {"x": 42})
611
- assert result[0].text == "42" # type: ignore[attr-defined]
 
612
 
613
  async def test_context_injection_async(self):
614
  """Test that context is properly injected in async tools."""
@@ -626,7 +648,8 @@ class TestContextHandling:
626
 
627
  async with context:
628
  result = await manager.call_tool("async_tool", {"x": 42})
629
- assert result[0].text == "42" # type: ignore[attr-defined]
 
630
 
631
  async def test_context_optional(self):
632
  """Test that context is optional when calling tools."""
@@ -644,7 +667,8 @@ class TestContextHandling:
644
 
645
  async with context:
646
  result = await manager.call_tool("tool_with_context", {"x": 42})
647
- assert result[0].text == "42" # type: ignore[attr-defined]
 
648
 
649
  def test_parameterized_context_parameter_detection(self):
650
  """Test that context parameters are properly detected in
@@ -752,7 +776,8 @@ class TestCustomToolNames:
752
 
753
  # Tool should be callable by its custom name
754
  result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
755
- assert result[0].text == "15" # type: ignore[attr-defined]
 
756
 
757
  # Original name should not be registered
758
  with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
 
125
  tool = await manager.get_tool("image_tool")
126
  result = await tool.run({"data": "test.png"})
127
  assert tool.parameters["properties"]["data"]["type"] == "string"
128
+ assert isinstance(result.content[0], ImageContent)
129
+ assert result.structured_content is None
130
 
131
  def test_add_noncallable_tool(self):
132
  manager = ToolManager()
 
354
  manager.add_tool(tool)
355
  result = await manager.call_tool("add", {"a": 1, "b": 2})
356
 
357
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
358
+ assert result.structured_content == {"value": 3}
359
 
360
  async def test_call_async_tool(self):
361
  async def double(n: int) -> int:
 
366
  tool = Tool.from_function(double)
367
  manager.add_tool(tool)
368
  result = await manager.call_tool("double", {"n": 5})
369
+ assert result.content[0].text == "10" # type: ignore[attr-defined]
370
+ assert result.structured_content == {"value": 10}
371
 
372
  async def test_call_tool_callable_object(self):
373
  class Adder:
 
381
  tool = Tool.from_function(Adder())
382
  manager.add_tool(tool)
383
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
384
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
385
+ assert result.structured_content == {"value": 3}
386
 
387
  async def test_call_tool_callable_object_async(self):
388
  class Adder:
 
396
  tool = Tool.from_function(Adder())
397
  manager.add_tool(tool)
398
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
399
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
400
+ assert result.structured_content == {"value": 3}
401
 
402
  async def test_call_tool_with_default_args(self):
403
  def add(a: int, b: int = 1) -> int:
 
409
  manager.add_tool(tool)
410
  result = await manager.call_tool("add", {"a": 1})
411
 
412
+ assert result.content[0].text == "2" # type: ignore[attr-defined]
413
+ assert result.structured_content == {"value": 2}
414
 
415
  async def test_call_tool_with_missing_args(self):
416
  def add(a: int, b: int) -> int:
 
437
  manager.add_tool(tool)
438
 
439
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
440
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
441
+ assert result.structured_content == {"value": 6}
442
 
443
  async def test_call_tool_with_list_str_or_str_input(self):
444
  def concat_strs(vals: list[str] | str) -> str:
 
450
 
451
  # Try both with plain python object and with JSON list
452
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
453
+ assert result.content[0].text == "abc" # type: ignore[attr-defined]
454
+ assert result.structured_content == {"value": "abc"}
455
 
456
  result = await manager.call_tool("concat_strs", {"vals": "a"})
457
+ assert result.content[0].text == "a" # type: ignore[attr-defined]
458
+ assert result.structured_content == {"value": "a"}
459
 
460
  async def test_call_tool_with_complex_model(self):
461
  class MyShrimpTank(BaseModel):
 
486
  },
487
  )
488
 
489
+ assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
490
+ assert result.structured_content == {"value": ["rex", "gertrude"]}
491
 
492
  async def test_call_tool_with_custom_serializer(self):
493
  """Test that a custom serializer provided to FastMCP is used by tools."""
 
506
  return {"key": "value", "number": 123}
507
 
508
  result = await manager.call_tool("get_data", {})
509
+ assert result.content[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
510
+ assert result.structured_content == {"key": "value", "number": 123}
511
 
512
  async def test_call_tool_with_list_result_custom_serializer(self):
513
  """Test that a custom serializer provided to FastMCP is used by tools that return lists."""
 
529
 
530
  result = await manager.call_tool("get_data", {})
531
  assert (
532
+ result.content[0].text # type: ignore[attr-defined]
533
  == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
534
  )
535
+ assert result.structured_content == {
536
+ "value": [
537
+ {"key": "value", "number": 123},
538
+ {"key": "value2", "number": 456},
539
+ ]
540
+ }
541
 
542
  async def test_custom_serializer_fallback_on_error(self):
543
  """Test that a broken custom serializer gracefully falls back."""
 
555
  return uuid_result
556
 
557
  result = await manager.call_tool("get_data", {})
558
+ assert (
559
+ result.content[0].text # type: ignore[attr-defined]
560
+ == pydantic_core.to_json(uuid_result).decode()
561
+ )
562
+ assert result.structured_content == {"value": str(uuid_result)}
563
 
564
 
565
  class TestToolSchema:
 
629
 
630
  async with context:
631
  result = await manager.call_tool("tool_with_context", {"x": 42})
632
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
633
+ assert result.structured_content == {"value": "42"}
634
 
635
  async def test_context_injection_async(self):
636
  """Test that context is properly injected in async tools."""
 
648
 
649
  async with context:
650
  result = await manager.call_tool("async_tool", {"x": 42})
651
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
652
+ assert result.structured_content == {"value": "42"}
653
 
654
  async def test_context_optional(self):
655
  """Test that context is optional when calling tools."""
 
667
 
668
  async with context:
669
  result = await manager.call_tool("tool_with_context", {"x": 42})
670
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
671
+ assert result.structured_content == {"value": 42}
672
 
673
  def test_parameterized_context_parameter_detection(self):
674
  """Test that context parameters are properly detected in
 
776
 
777
  # Tool should be callable by its custom name
778
  result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
779
+ assert result.content[0].text == "15" # type: ignore[attr-defined]
780
+ assert result.structured_content == {"value": 15}
781
 
782
  # Original name should not be registered
783
  with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
tests/tools/test_tool_transform.py CHANGED
@@ -52,7 +52,8 @@ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
52
  add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
53
  )
54
  result = await new_tool.run(arguments={"new_x": 1})
55
- assert result[0].text == "11" # type: ignore[attr-defined]
 
56
 
57
 
58
  async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
@@ -60,7 +61,8 @@ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
60
  add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
61
  )
62
  result = await new_tool.run(arguments={"old_x": 1})
63
- assert result[0].text == "11" # type: ignore[attr-defined]
 
64
 
65
 
66
  def test_tool_change_arg_name(add_tool):
@@ -87,7 +89,7 @@ async def test_tool_drop_arg(add_tool):
87
  )
88
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
89
  result = await new_tool.run(arguments={"old_x": 1})
90
- assert result[0].text == "11" # type: ignore[attr-defined]
91
 
92
 
93
  async def test_dropped_args_error_if_provided(add_tool):
@@ -109,7 +111,7 @@ async def test_hidden_arg_with_constant_default(add_tool):
109
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
110
  # Should pass old_x=5 and old_y=20 to parent
111
  result = await new_tool.run(arguments={"old_x": 5})
112
- assert result[0].text == "25" # type: ignore[attr-defined]
113
 
114
 
115
  async def test_hidden_arg_without_default_uses_parent_default(add_tool):
@@ -121,7 +123,8 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool):
121
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
122
  # Should pass old_x=3 and let parent use its default old_y=10
123
  result = await new_tool.run(arguments={"old_x": 3})
124
- assert result[0].text == "13" # type: ignore[attr-defined]
 
125
 
126
 
127
  async def test_mixed_hidden_args_with_custom_function(add_tool):
@@ -146,7 +149,8 @@ async def test_mixed_hidden_args_with_custom_function(add_tool):
146
  assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
147
  # Should pass visible_x=7 as old_x=7 and old_y=25 to parent
148
  result = await new_tool.run(arguments={"visible_x": 7})
149
- assert result[0].text == "32" # type: ignore[attr-defined]
 
150
 
151
 
152
  async def test_hide_required_param_without_default_raises_error():
@@ -184,7 +188,7 @@ async def test_hide_required_param_with_user_default_works():
184
  assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
185
  # Should pass required_param=5 and optional_param=20 to parent
186
  result = await new_tool.run(arguments={"optional_param": 20})
187
- assert result[0].text == "25" # type: ignore[attr-defined]
188
 
189
 
190
  async def test_forward_with_argument_mapping(add_tool):
@@ -203,7 +207,8 @@ async def test_forward_with_argument_mapping(add_tool):
203
  )
204
 
205
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
206
- assert result[0].text == "5" # type: ignore[attr-defined]
 
207
 
208
 
209
  async def test_forward_with_incorrect_args_raises_error(add_tool):
@@ -243,7 +248,8 @@ async def test_forward_raw_without_argument_mapping(add_tool):
243
  )
244
 
245
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
246
- assert result[0].text == "5" # type: ignore[attr-defined]
 
247
 
248
 
249
  async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
@@ -253,7 +259,8 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
253
 
254
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
255
  result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
256
- assert result[0].text == "6" # type: ignore[attr-defined]
 
257
  assert new_tool.parameters["required"] == IsList(
258
  "extra", "old_x", check_order=False
259
  )
@@ -270,7 +277,8 @@ async def test_fn_with_kwargs_passes_through_original_args(add_tool):
270
 
271
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
272
  result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
273
- assert result[0].text == "5" # type: ignore[attr-defined]
 
274
 
275
 
276
  async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
@@ -288,7 +296,8 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
288
  transform_args={"old_x": ArgTransform(name="new_x")},
289
  )
290
  result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
291
- assert result[0].text == "5" # type: ignore[attr-defined]
 
292
 
293
 
294
  async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
@@ -308,7 +317,8 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
308
  result = await new_tool.run(
309
  arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
310
  )
311
- assert result[0].text == "10" # type: ignore[attr-defined]
 
312
 
313
 
314
  async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
@@ -326,7 +336,8 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
326
  transform_args={"old_x": ArgTransform(name="new_x")},
327
  ) # only map 'a'
328
  result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
329
- assert result[0].text == "6" # type: ignore[attr-defined]
 
330
 
331
 
332
  async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
 
52
  add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
53
  )
54
  result = await new_tool.run(arguments={"new_x": 1})
55
+ # The parent tool returns int which gets wrapped as structured output
56
+ assert result.structured_content == {"value": 11}
57
 
58
 
59
  async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
 
61
  add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
62
  )
63
  result = await new_tool.run(arguments={"old_x": 1})
64
+ # The parent tool returns int which gets wrapped as structured output
65
+ assert result.structured_content == {"value": 11}
66
 
67
 
68
  def test_tool_change_arg_name(add_tool):
 
89
  )
90
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
91
  result = await new_tool.run(arguments={"old_x": 1})
92
+ assert result.structured_content == {"value": 11}
93
 
94
 
95
  async def test_dropped_args_error_if_provided(add_tool):
 
111
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
112
  # Should pass old_x=5 and old_y=20 to parent
113
  result = await new_tool.run(arguments={"old_x": 5})
114
+ assert result.structured_content == {"value": 25}
115
 
116
 
117
  async def test_hidden_arg_without_default_uses_parent_default(add_tool):
 
123
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
124
  # Should pass old_x=3 and let parent use its default old_y=10
125
  result = await new_tool.run(arguments={"old_x": 3})
126
+ assert result.content[0].text == "13" # type: ignore[attr-defined]
127
+ assert result.structured_content == {"value": 13}
128
 
129
 
130
  async def test_mixed_hidden_args_with_custom_function(add_tool):
 
149
  assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
150
  # Should pass visible_x=7 as old_x=7 and old_y=25 to parent
151
  result = await new_tool.run(arguments={"visible_x": 7})
152
+ assert result.content[0].text == "32" # type: ignore[attr-defined]
153
+ assert result.structured_content == {"value": 32}
154
 
155
 
156
  async def test_hide_required_param_without_default_raises_error():
 
188
  assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
189
  # Should pass required_param=5 and optional_param=20 to parent
190
  result = await new_tool.run(arguments={"optional_param": 20})
191
+ assert result.structured_content == {"value": 25}
192
 
193
 
194
  async def test_forward_with_argument_mapping(add_tool):
 
207
  )
208
 
209
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
210
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
211
+ assert result.structured_content == {"value": 5}
212
 
213
 
214
  async def test_forward_with_incorrect_args_raises_error(add_tool):
 
248
  )
249
 
250
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
251
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
252
+ assert result.structured_content == {"value": 5}
253
 
254
 
255
  async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
 
259
 
260
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
261
  result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
262
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
263
+ assert result.structured_content == {"value": 6}
264
  assert new_tool.parameters["required"] == IsList(
265
  "extra", "old_x", check_order=False
266
  )
 
277
 
278
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
279
  result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
280
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
281
+ assert result.structured_content == {"value": 5}
282
 
283
 
284
  async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
 
296
  transform_args={"old_x": ArgTransform(name="new_x")},
297
  )
298
  result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
299
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
300
+ assert result.structured_content == {"value": 5}
301
 
302
 
303
  async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
 
317
  result = await new_tool.run(
318
  arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
319
  )
320
+ assert result.content[0].text == "10" # type: ignore[attr-defined]
321
+ assert result.structured_content == {"value": 10}
322
 
323
 
324
  async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
 
336
  transform_args={"old_x": ArgTransform(name="new_x")},
337
  ) # only map 'a'
338
  result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
339
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
340
+ assert result.structured_content == {"value": 6}
341
 
342
 
343
  async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
tests/utilities/test_json_schema_type.py ADDED
@@ -0,0 +1,1418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Union
3
+
4
+ import pytest
5
+ from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
6
+
7
+ from fastmcp.utilities.json_schema_type import (
8
+ _hash_schema,
9
+ _merge_defaults,
10
+ json_schema_to_type,
11
+ )
12
+
13
+
14
+ class TestSimpleTypes:
15
+ """Test suite for basic type validation."""
16
+
17
+ @pytest.fixture
18
+ def simple_string(self):
19
+ return json_schema_to_type({"type": "string"})
20
+
21
+ @pytest.fixture
22
+ def simple_number(self):
23
+ return json_schema_to_type({"type": "number"})
24
+
25
+ @pytest.fixture
26
+ def simple_integer(self):
27
+ return json_schema_to_type({"type": "integer"})
28
+
29
+ @pytest.fixture
30
+ def simple_boolean(self):
31
+ return json_schema_to_type({"type": "boolean"})
32
+
33
+ @pytest.fixture
34
+ def simple_null(self):
35
+ return json_schema_to_type({"type": "null"})
36
+
37
+ def test_string_accepts_string(self, simple_string):
38
+ validator = TypeAdapter(simple_string)
39
+ assert validator.validate_python("test") == "test"
40
+
41
+ def test_string_rejects_number(self, simple_string):
42
+ validator = TypeAdapter(simple_string)
43
+ with pytest.raises(ValidationError):
44
+ validator.validate_python(123)
45
+
46
+ def test_number_accepts_float(self, simple_number):
47
+ validator = TypeAdapter(simple_number)
48
+ assert validator.validate_python(123.45) == 123.45
49
+
50
+ def test_number_accepts_integer(self, simple_number):
51
+ validator = TypeAdapter(simple_number)
52
+ assert validator.validate_python(123) == 123
53
+
54
+ def test_number_accepts_numeric_string(self, simple_number):
55
+ validator = TypeAdapter(simple_number)
56
+ assert validator.validate_python("123.45") == 123.45
57
+ assert validator.validate_python("123") == 123
58
+
59
+ def test_number_rejects_invalid_string(self, simple_number):
60
+ validator = TypeAdapter(simple_number)
61
+ with pytest.raises(ValidationError):
62
+ validator.validate_python("not a number")
63
+
64
+ def test_integer_accepts_integer(self, simple_integer):
65
+ validator = TypeAdapter(simple_integer)
66
+ assert validator.validate_python(123) == 123
67
+
68
+ def test_integer_accepts_integer_string(self, simple_integer):
69
+ validator = TypeAdapter(simple_integer)
70
+ assert validator.validate_python("123") == 123
71
+
72
+ def test_integer_rejects_float(self, simple_integer):
73
+ validator = TypeAdapter(simple_integer)
74
+ with pytest.raises(ValidationError):
75
+ validator.validate_python(123.45)
76
+
77
+ def test_integer_rejects_float_string(self, simple_integer):
78
+ validator = TypeAdapter(simple_integer)
79
+ with pytest.raises(ValidationError):
80
+ validator.validate_python("123.45")
81
+
82
+ def test_boolean_accepts_boolean(self, simple_boolean):
83
+ validator = TypeAdapter(simple_boolean)
84
+ assert validator.validate_python(True) is True
85
+ assert validator.validate_python(False) is False
86
+
87
+ def test_boolean_accepts_boolean_strings(self, simple_boolean):
88
+ validator = TypeAdapter(simple_boolean)
89
+ assert validator.validate_python("true") is True
90
+ assert validator.validate_python("True") is True
91
+ assert validator.validate_python("false") is False
92
+ assert validator.validate_python("False") is False
93
+
94
+ def test_boolean_rejects_invalid_string(self, simple_boolean):
95
+ validator = TypeAdapter(simple_boolean)
96
+ with pytest.raises(ValidationError):
97
+ validator.validate_python("not a boolean")
98
+
99
+ def test_null_accepts_none(self, simple_null):
100
+ validator = TypeAdapter(simple_null)
101
+ assert validator.validate_python(None) is None
102
+
103
+ def test_null_rejects_false(self, simple_null):
104
+ validator = TypeAdapter(simple_null)
105
+ with pytest.raises(ValidationError):
106
+ validator.validate_python(False)
107
+
108
+
109
+ class TestStringConstraints:
110
+ """Test suite for string constraint validation."""
111
+
112
+ @pytest.fixture
113
+ def min_length_string(self):
114
+ return json_schema_to_type({"type": "string", "minLength": 3})
115
+
116
+ @pytest.fixture
117
+ def max_length_string(self):
118
+ return json_schema_to_type({"type": "string", "maxLength": 5})
119
+
120
+ @pytest.fixture
121
+ def pattern_string(self):
122
+ return json_schema_to_type({"type": "string", "pattern": "^[A-Z][a-z]+$"})
123
+
124
+ @pytest.fixture
125
+ def email_string(self):
126
+ return json_schema_to_type({"type": "string", "format": "email"})
127
+
128
+ def test_min_length_accepts_valid(self, min_length_string):
129
+ validator = TypeAdapter(min_length_string)
130
+ assert validator.validate_python("test") == "test"
131
+
132
+ def test_min_length_rejects_short(self, min_length_string):
133
+ validator = TypeAdapter(min_length_string)
134
+ with pytest.raises(ValidationError):
135
+ validator.validate_python("ab")
136
+
137
+ def test_max_length_accepts_valid(self, max_length_string):
138
+ validator = TypeAdapter(max_length_string)
139
+ assert validator.validate_python("test") == "test"
140
+
141
+ def test_max_length_rejects_long(self, max_length_string):
142
+ validator = TypeAdapter(max_length_string)
143
+ with pytest.raises(ValidationError):
144
+ validator.validate_python("toolong")
145
+
146
+ def test_pattern_accepts_valid(self, pattern_string):
147
+ validator = TypeAdapter(pattern_string)
148
+ assert validator.validate_python("Hello") == "Hello"
149
+
150
+ def test_pattern_rejects_invalid(self, pattern_string):
151
+ validator = TypeAdapter(pattern_string)
152
+ with pytest.raises(ValidationError):
153
+ validator.validate_python("hello")
154
+
155
+ def test_email_accepts_valid(self, email_string):
156
+ validator = TypeAdapter(email_string)
157
+ result = validator.validate_python("test@example.com")
158
+ assert result == "test@example.com"
159
+
160
+ def test_email_rejects_invalid(self, email_string):
161
+ validator = TypeAdapter(email_string)
162
+ with pytest.raises(ValidationError):
163
+ validator.validate_python("not-an-email")
164
+
165
+
166
+ class TestNumberConstraints:
167
+ """Test suite for numeric constraint validation."""
168
+
169
+ @pytest.fixture
170
+ def multiple_of_number(self):
171
+ return json_schema_to_type({"type": "number", "multipleOf": 0.5})
172
+
173
+ @pytest.fixture
174
+ def min_number(self):
175
+ return json_schema_to_type({"type": "number", "minimum": 0})
176
+
177
+ @pytest.fixture
178
+ def exclusive_min_number(self):
179
+ return json_schema_to_type({"type": "number", "exclusiveMinimum": 0})
180
+
181
+ @pytest.fixture
182
+ def max_number(self):
183
+ return json_schema_to_type({"type": "number", "maximum": 100})
184
+
185
+ @pytest.fixture
186
+ def exclusive_max_number(self):
187
+ return json_schema_to_type({"type": "number", "exclusiveMaximum": 100})
188
+
189
+ def test_multiple_of_accepts_valid(self, multiple_of_number):
190
+ validator = TypeAdapter(multiple_of_number)
191
+ assert validator.validate_python(2.5) == 2.5
192
+
193
+ def test_multiple_of_rejects_invalid(self, multiple_of_number):
194
+ validator = TypeAdapter(multiple_of_number)
195
+ with pytest.raises(ValidationError):
196
+ validator.validate_python(2.7)
197
+
198
+ def test_minimum_accepts_equal(self, min_number):
199
+ validator = TypeAdapter(min_number)
200
+ assert validator.validate_python(0) == 0
201
+
202
+ def test_minimum_rejects_less(self, min_number):
203
+ validator = TypeAdapter(min_number)
204
+ with pytest.raises(ValidationError):
205
+ validator.validate_python(-1)
206
+
207
+ def test_exclusive_minimum_rejects_equal(self, exclusive_min_number):
208
+ validator = TypeAdapter(exclusive_min_number)
209
+ with pytest.raises(ValidationError):
210
+ validator.validate_python(0)
211
+
212
+ def test_maximum_accepts_equal(self, max_number):
213
+ validator = TypeAdapter(max_number)
214
+ assert validator.validate_python(100) == 100
215
+
216
+ def test_maximum_rejects_greater(self, max_number):
217
+ validator = TypeAdapter(max_number)
218
+ with pytest.raises(ValidationError):
219
+ validator.validate_python(101)
220
+
221
+ def test_exclusive_maximum_rejects_equal(self, exclusive_max_number):
222
+ validator = TypeAdapter(exclusive_max_number)
223
+ with pytest.raises(ValidationError):
224
+ validator.validate_python(100)
225
+
226
+
227
+ class TestArrayTypes:
228
+ """Test suite for array validation."""
229
+
230
+ @pytest.fixture
231
+ def string_array(self):
232
+ return json_schema_to_type({"type": "array", "items": {"type": "string"}})
233
+
234
+ @pytest.fixture
235
+ def min_items_array(self):
236
+ return json_schema_to_type(
237
+ {"type": "array", "items": {"type": "string"}, "minItems": 2}
238
+ )
239
+
240
+ @pytest.fixture
241
+ def max_items_array(self):
242
+ return json_schema_to_type(
243
+ {"type": "array", "items": {"type": "string"}, "maxItems": 3}
244
+ )
245
+
246
+ @pytest.fixture
247
+ def unique_items_array(self):
248
+ return json_schema_to_type(
249
+ {"type": "array", "items": {"type": "string"}, "uniqueItems": True}
250
+ )
251
+
252
+ def test_array_accepts_valid_items(self, string_array):
253
+ validator = TypeAdapter(string_array)
254
+ assert validator.validate_python(["a", "b"]) == ["a", "b"]
255
+
256
+ def test_array_rejects_invalid_items(self, string_array):
257
+ validator = TypeAdapter(string_array)
258
+ with pytest.raises(ValidationError):
259
+ validator.validate_python([1, "b"])
260
+
261
+ def test_min_items_accepts_valid(self, min_items_array):
262
+ validator = TypeAdapter(min_items_array)
263
+ assert validator.validate_python(["a", "b"]) == ["a", "b"]
264
+
265
+ def test_min_items_rejects_too_few(self, min_items_array):
266
+ validator = TypeAdapter(min_items_array)
267
+ with pytest.raises(ValidationError):
268
+ validator.validate_python(["a"])
269
+
270
+ def test_max_items_accepts_valid(self, max_items_array):
271
+ validator = TypeAdapter(max_items_array)
272
+ assert validator.validate_python(["a", "b", "c"]) == ["a", "b", "c"]
273
+
274
+ def test_max_items_rejects_too_many(self, max_items_array):
275
+ validator = TypeAdapter(max_items_array)
276
+ with pytest.raises(ValidationError):
277
+ validator.validate_python(["a", "b", "c", "d"])
278
+
279
+ def test_unique_items_accepts_unique(self, unique_items_array):
280
+ validator = TypeAdapter(unique_items_array)
281
+ assert isinstance(validator.validate_python(["a", "b"]), set)
282
+
283
+ def test_unique_items_converts_duplicates(self, unique_items_array):
284
+ validator = TypeAdapter(unique_items_array)
285
+ result = validator.validate_python(["a", "a", "b"])
286
+ assert result == {"a", "b"}
287
+
288
+
289
+ class TestObjectTypes:
290
+ """Test suite for object validation."""
291
+
292
+ @pytest.fixture
293
+ def simple_object(self):
294
+ return json_schema_to_type(
295
+ {
296
+ "type": "object",
297
+ "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
298
+ }
299
+ )
300
+
301
+ @pytest.fixture
302
+ def required_object(self):
303
+ return json_schema_to_type(
304
+ {
305
+ "type": "object",
306
+ "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
307
+ "required": ["name"],
308
+ }
309
+ )
310
+
311
+ @pytest.fixture
312
+ def nested_object(self):
313
+ return json_schema_to_type(
314
+ {
315
+ "type": "object",
316
+ "properties": {
317
+ "user": {
318
+ "type": "object",
319
+ "properties": {
320
+ "name": {"type": "string"},
321
+ "age": {"type": "integer"},
322
+ },
323
+ "required": ["name"],
324
+ }
325
+ },
326
+ }
327
+ )
328
+
329
+ def test_object_accepts_valid(self, simple_object):
330
+ validator = TypeAdapter(simple_object)
331
+ result = validator.validate_python({"name": "test", "age": 30})
332
+ assert result.name == "test"
333
+ assert result.age == 30
334
+
335
+ def test_object_accepts_extra_properties(self, simple_object):
336
+ validator = TypeAdapter(simple_object)
337
+ result = validator.validate_python(
338
+ {"name": "test", "age": 30, "extra": "field"}
339
+ )
340
+ assert result.name == "test"
341
+ assert result.age == 30
342
+ assert not hasattr(result, "extra")
343
+
344
+ def test_required_accepts_valid(self, required_object):
345
+ validator = TypeAdapter(required_object)
346
+ result = validator.validate_python({"name": "test"})
347
+ assert result.name == "test"
348
+ assert result.age is None
349
+
350
+ def test_required_rejects_missing(self, required_object):
351
+ validator = TypeAdapter(required_object)
352
+ with pytest.raises(ValidationError):
353
+ validator.validate_python({})
354
+
355
+ def test_nested_accepts_valid(self, nested_object):
356
+ validator = TypeAdapter(nested_object)
357
+ result = validator.validate_python({"user": {"name": "test", "age": 30}})
358
+ assert result.user.name == "test"
359
+ assert result.user.age == 30
360
+
361
+ def test_nested_rejects_invalid(self, nested_object):
362
+ validator = TypeAdapter(nested_object)
363
+ with pytest.raises(ValidationError):
364
+ validator.validate_python({"user": {"age": 30}})
365
+
366
+
367
+ class TestDefaultValues:
368
+ """Test suite for default value handling."""
369
+
370
+ @pytest.fixture
371
+ def simple_defaults(self):
372
+ return json_schema_to_type(
373
+ {
374
+ "type": "object",
375
+ "properties": {
376
+ "name": {"type": "string", "default": "anonymous"},
377
+ "age": {"type": "integer", "default": 0},
378
+ },
379
+ }
380
+ )
381
+
382
+ @pytest.fixture
383
+ def nested_defaults(self):
384
+ return json_schema_to_type(
385
+ {
386
+ "type": "object",
387
+ "properties": {
388
+ "user": {
389
+ "type": "object",
390
+ "properties": {
391
+ "name": {"type": "string", "default": "anonymous"},
392
+ "settings": {
393
+ "type": "object",
394
+ "properties": {
395
+ "theme": {"type": "string", "default": "light"}
396
+ },
397
+ "default": {"theme": "dark"},
398
+ },
399
+ },
400
+ "default": {"name": "guest", "settings": {"theme": "system"}},
401
+ }
402
+ },
403
+ }
404
+ )
405
+
406
+ def test_simple_defaults_empty_object(self, simple_defaults):
407
+ validator = TypeAdapter(simple_defaults)
408
+ result = validator.validate_python({})
409
+ assert result.name == "anonymous"
410
+ assert result.age == 0
411
+
412
+ def test_simple_defaults_partial_override(self, simple_defaults):
413
+ validator = TypeAdapter(simple_defaults)
414
+ result = validator.validate_python({"name": "test"})
415
+ assert result.name == "test"
416
+ assert result.age == 0
417
+
418
+ def test_nested_defaults_empty_object(self, nested_defaults):
419
+ validator = TypeAdapter(nested_defaults)
420
+ result = validator.validate_python({})
421
+ assert result.user.name == "guest"
422
+ assert result.user.settings.theme == "system"
423
+
424
+ def test_nested_defaults_partial_override(self, nested_defaults):
425
+ validator = TypeAdapter(nested_defaults)
426
+ result = validator.validate_python({"user": {"name": "test"}})
427
+ assert result.user.name == "test"
428
+ assert result.user.settings.theme == "system"
429
+
430
+
431
+ class TestUnionTypes:
432
+ """Test suite for testing union type behaviors."""
433
+
434
+ @pytest.fixture
435
+ def heterogeneous_union(self):
436
+ return json_schema_to_type({"type": ["string", "number", "boolean", "null"]})
437
+
438
+ @pytest.fixture
439
+ def union_with_constraints(self):
440
+ return json_schema_to_type(
441
+ {"type": ["string", "number"], "minLength": 3, "minimum": 0}
442
+ )
443
+
444
+ @pytest.fixture
445
+ def union_with_formats(self):
446
+ return json_schema_to_type({"type": ["string", "null"], "format": "email"})
447
+
448
+ @pytest.fixture
449
+ def nested_union_array(self):
450
+ return json_schema_to_type(
451
+ {"type": "array", "items": {"type": ["string", "number"]}}
452
+ )
453
+
454
+ @pytest.fixture
455
+ def nested_union_object(self):
456
+ return json_schema_to_type(
457
+ {
458
+ "type": "object",
459
+ "properties": {
460
+ "id": {"type": ["string", "integer"]},
461
+ "data": {
462
+ "type": ["object", "null"],
463
+ "properties": {"value": {"type": "string"}},
464
+ },
465
+ },
466
+ }
467
+ )
468
+
469
+ def test_heterogeneous_accepts_string(self, heterogeneous_union):
470
+ validator = TypeAdapter(heterogeneous_union)
471
+ assert validator.validate_python("test") == "test"
472
+
473
+ def test_heterogeneous_accepts_number(self, heterogeneous_union):
474
+ validator = TypeAdapter(heterogeneous_union)
475
+ assert validator.validate_python(123.45) == 123.45
476
+
477
+ def test_heterogeneous_accepts_boolean(self, heterogeneous_union):
478
+ validator = TypeAdapter(heterogeneous_union)
479
+ assert validator.validate_python(True) is True
480
+
481
+ def test_heterogeneous_accepts_null(self, heterogeneous_union):
482
+ validator = TypeAdapter(heterogeneous_union)
483
+ assert validator.validate_python(None) is None
484
+
485
+ def test_heterogeneous_rejects_array(self, heterogeneous_union):
486
+ validator = TypeAdapter(heterogeneous_union)
487
+ with pytest.raises(ValidationError):
488
+ validator.validate_python([])
489
+
490
+ def test_constrained_string_valid(self, union_with_constraints):
491
+ validator = TypeAdapter(union_with_constraints)
492
+ assert validator.validate_python("test") == "test"
493
+
494
+ def test_constrained_string_invalid(self, union_with_constraints):
495
+ validator = TypeAdapter(union_with_constraints)
496
+ with pytest.raises(ValidationError):
497
+ validator.validate_python("ab")
498
+
499
+ def test_constrained_number_valid(self, union_with_constraints):
500
+ validator = TypeAdapter(union_with_constraints)
501
+ assert validator.validate_python(10) == 10
502
+
503
+ def test_constrained_number_invalid(self, union_with_constraints):
504
+ validator = TypeAdapter(union_with_constraints)
505
+ with pytest.raises(ValidationError):
506
+ validator.validate_python(-1)
507
+
508
+ def test_format_valid_email(self, union_with_formats):
509
+ validator = TypeAdapter(union_with_formats)
510
+ result = validator.validate_python("test@example.com")
511
+ assert isinstance(result, str)
512
+
513
+ def test_format_valid_null(self, union_with_formats):
514
+ validator = TypeAdapter(union_with_formats)
515
+ assert validator.validate_python(None) is None
516
+
517
+ def test_format_invalid_email(self, union_with_formats):
518
+ validator = TypeAdapter(union_with_formats)
519
+ with pytest.raises(ValidationError):
520
+ validator.validate_python("not-an-email")
521
+
522
+ def test_nested_array_mixed_types(self, nested_union_array):
523
+ validator = TypeAdapter(nested_union_array)
524
+ result = validator.validate_python(["test", 123, "abc"])
525
+ assert result == ["test", 123, "abc"]
526
+
527
+ def test_nested_array_rejects_invalid(self, nested_union_array):
528
+ validator = TypeAdapter(nested_union_array)
529
+ with pytest.raises(ValidationError):
530
+ validator.validate_python(["test", ["not", "allowed"], "abc"])
531
+
532
+ def test_nested_object_string_id(self, nested_union_object):
533
+ validator = TypeAdapter(nested_union_object)
534
+ result = validator.validate_python({"id": "abc123", "data": {"value": "test"}})
535
+ assert result.id == "abc123"
536
+ assert result.data.value == "test"
537
+
538
+ def test_nested_object_integer_id(self, nested_union_object):
539
+ validator = TypeAdapter(nested_union_object)
540
+ result = validator.validate_python({"id": 123, "data": None})
541
+ assert result.id == 123
542
+ assert result.data is None
543
+
544
+
545
+ class TestFormatTypes:
546
+ """Test suite for format type validation."""
547
+
548
+ @pytest.fixture
549
+ def datetime_format(self):
550
+ return json_schema_to_type({"type": "string", "format": "date-time"})
551
+
552
+ @pytest.fixture
553
+ def email_format(self):
554
+ return json_schema_to_type({"type": "string", "format": "email"})
555
+
556
+ @pytest.fixture
557
+ def uri_format(self):
558
+ return json_schema_to_type({"type": "string", "format": "uri"})
559
+
560
+ @pytest.fixture
561
+ def uri_reference_format(self):
562
+ return json_schema_to_type({"type": "string", "format": "uri-reference"})
563
+
564
+ @pytest.fixture
565
+ def json_format(self):
566
+ return json_schema_to_type({"type": "string", "format": "json"})
567
+
568
+ @pytest.fixture
569
+ def mixed_formats_object(self):
570
+ return json_schema_to_type(
571
+ {
572
+ "type": "object",
573
+ "properties": {
574
+ "full_uri": {"type": "string", "format": "uri"},
575
+ "ref_uri": {"type": "string", "format": "uri-reference"},
576
+ },
577
+ }
578
+ )
579
+
580
+ def test_datetime_valid(self, datetime_format):
581
+ validator = TypeAdapter(datetime_format)
582
+ result = validator.validate_python("2024-01-17T12:34:56Z")
583
+ assert isinstance(result, datetime)
584
+
585
+ def test_datetime_invalid(self, datetime_format):
586
+ validator = TypeAdapter(datetime_format)
587
+ with pytest.raises(ValidationError):
588
+ validator.validate_python("not-a-date")
589
+
590
+ def test_email_valid(self, email_format):
591
+ validator = TypeAdapter(email_format)
592
+ result = validator.validate_python("test@example.com")
593
+ assert isinstance(result, str)
594
+
595
+ def test_email_invalid(self, email_format):
596
+ validator = TypeAdapter(email_format)
597
+ with pytest.raises(ValidationError):
598
+ validator.validate_python("not-an-email")
599
+
600
+ def test_uri_valid(self, uri_format):
601
+ validator = TypeAdapter(uri_format)
602
+ result = validator.validate_python("https://example.com")
603
+ assert isinstance(result, AnyUrl)
604
+
605
+ def test_uri_invalid(self, uri_format):
606
+ validator = TypeAdapter(uri_format)
607
+ with pytest.raises(ValidationError):
608
+ validator.validate_python("not-a-uri")
609
+
610
+ def test_uri_reference_valid(self, uri_reference_format):
611
+ validator = TypeAdapter(uri_reference_format)
612
+ result = validator.validate_python("https://example.com")
613
+ assert isinstance(result, str)
614
+
615
+ def test_uri_reference_relative_valid(self, uri_reference_format):
616
+ validator = TypeAdapter(uri_reference_format)
617
+ result = validator.validate_python("/path/to/resource")
618
+ assert isinstance(result, str)
619
+
620
+ def test_uri_reference_invalid(self, uri_reference_format):
621
+ validator = TypeAdapter(uri_reference_format)
622
+ result = validator.validate_python("not a uri")
623
+ assert isinstance(result, str)
624
+
625
+ def test_json_valid(self, json_format):
626
+ validator = TypeAdapter(json_format)
627
+ result = validator.validate_python('{"key": "value"}')
628
+ assert isinstance(result, dict)
629
+
630
+ def test_json_invalid(self, json_format):
631
+ validator = TypeAdapter(json_format)
632
+ with pytest.raises(ValidationError):
633
+ validator.validate_python("{invalid json}")
634
+
635
+ def test_mixed_formats_object(self, mixed_formats_object):
636
+ validator = TypeAdapter(mixed_formats_object)
637
+ result = validator.validate_python(
638
+ {"full_uri": "https://example.com", "ref_uri": "/path/to/resource"}
639
+ )
640
+ assert isinstance(result.full_uri, AnyUrl)
641
+ assert isinstance(result.ref_uri, str)
642
+
643
+
644
+ class TestCircularReferences:
645
+ """Test suite for circular reference handling."""
646
+
647
+ @pytest.fixture
648
+ def self_referential(self):
649
+ return json_schema_to_type(
650
+ {
651
+ "type": "object",
652
+ "properties": {"name": {"type": "string"}, "child": {"$ref": "#"}},
653
+ }
654
+ )
655
+
656
+ @pytest.fixture
657
+ def mutually_recursive(self):
658
+ return json_schema_to_type(
659
+ {
660
+ "type": "object",
661
+ "definitions": {
662
+ "Person": {
663
+ "type": "object",
664
+ "properties": {
665
+ "name": {"type": "string"},
666
+ "friend": {"$ref": "#/definitions/Pet"},
667
+ },
668
+ },
669
+ "Pet": {
670
+ "type": "object",
671
+ "properties": {
672
+ "name": {"type": "string"},
673
+ "owner": {"$ref": "#/definitions/Person"},
674
+ },
675
+ },
676
+ },
677
+ "properties": {"person": {"$ref": "#/definitions/Person"}},
678
+ }
679
+ )
680
+
681
+ def test_self_ref_single_level(self, self_referential):
682
+ validator = TypeAdapter(self_referential)
683
+ result = validator.validate_python(
684
+ {"name": "parent", "child": {"name": "child"}}
685
+ )
686
+ assert result.name == "parent"
687
+ assert result.child.name == "child"
688
+ assert result.child.child is None
689
+
690
+ def test_self_ref_multiple_levels(self, self_referential):
691
+ validator = TypeAdapter(self_referential)
692
+ result = validator.validate_python(
693
+ {
694
+ "name": "grandparent",
695
+ "child": {"name": "parent", "child": {"name": "child"}},
696
+ }
697
+ )
698
+ assert result.name == "grandparent"
699
+ assert result.child.name == "parent"
700
+ assert result.child.child.name == "child"
701
+
702
+ def test_mutual_recursion_single_level(self, mutually_recursive):
703
+ validator = TypeAdapter(mutually_recursive)
704
+ result = validator.validate_python(
705
+ {"person": {"name": "Alice", "friend": {"name": "Spot"}}}
706
+ )
707
+ assert result.person.name == "Alice"
708
+ assert result.person.friend.name == "Spot"
709
+ assert result.person.friend.owner is None
710
+
711
+ def test_mutual_recursion_multiple_levels(self, mutually_recursive):
712
+ validator = TypeAdapter(mutually_recursive)
713
+ result = validator.validate_python(
714
+ {
715
+ "person": {
716
+ "name": "Alice",
717
+ "friend": {"name": "Spot", "owner": {"name": "Bob"}},
718
+ }
719
+ }
720
+ )
721
+ assert result.person.name == "Alice"
722
+ assert result.person.friend.name == "Spot"
723
+ assert result.person.friend.owner.name == "Bob"
724
+
725
+
726
+ class TestIdentifierNormalization:
727
+ """Test suite for handling non-standard property names."""
728
+
729
+ @pytest.fixture
730
+ def special_chars(self):
731
+ return json_schema_to_type(
732
+ {
733
+ "type": "object",
734
+ "properties": {
735
+ "@type": {"type": "string"},
736
+ "first-name": {"type": "string"},
737
+ "last.name": {"type": "string"},
738
+ "2nd_address": {"type": "string"},
739
+ "$ref": {"type": "string"},
740
+ },
741
+ }
742
+ )
743
+
744
+ def test_normalizes_special_chars(self, special_chars):
745
+ validator = TypeAdapter(special_chars)
746
+ result = validator.validate_python(
747
+ {
748
+ "@type": "person",
749
+ "first-name": "Alice",
750
+ "last.name": "Smith",
751
+ "2nd_address": "456 Oak St",
752
+ "$ref": "12345",
753
+ }
754
+ )
755
+ assert result.field_type == "person" # @type -> field_type
756
+ assert result.first_name == "Alice" # first-name -> first_name
757
+ assert result.last_name == "Smith" # last.name -> last_name
758
+ assert (
759
+ result.field_2nd_address == "456 Oak St"
760
+ ) # 2nd_address -> field_2nd_address
761
+ assert result.field_ref == "12345" # $ref -> field_ref
762
+
763
+
764
+ class TestConstantValues:
765
+ """Test suite for constant value validation."""
766
+
767
+ @pytest.fixture
768
+ def string_const(self):
769
+ return json_schema_to_type({"type": "string", "const": "production"})
770
+
771
+ @pytest.fixture
772
+ def number_const(self):
773
+ return json_schema_to_type({"type": "number", "const": 42.5})
774
+
775
+ @pytest.fixture
776
+ def boolean_const(self):
777
+ return json_schema_to_type({"type": "boolean", "const": True})
778
+
779
+ @pytest.fixture
780
+ def null_const(self):
781
+ return json_schema_to_type({"type": "null", "const": None})
782
+
783
+ @pytest.fixture
784
+ def object_with_consts(self):
785
+ return json_schema_to_type(
786
+ {
787
+ "type": "object",
788
+ "properties": {
789
+ "env": {"const": "production"},
790
+ "version": {"const": 1},
791
+ "enabled": {"const": True},
792
+ },
793
+ }
794
+ )
795
+
796
+ def test_string_const_valid(self, string_const):
797
+ validator = TypeAdapter(string_const)
798
+ assert validator.validate_python("production") == "production"
799
+
800
+ def test_string_const_invalid(self, string_const):
801
+ validator = TypeAdapter(string_const)
802
+ with pytest.raises(ValidationError):
803
+ validator.validate_python("development")
804
+
805
+ def test_number_const_valid(self, number_const):
806
+ validator = TypeAdapter(number_const)
807
+ assert validator.validate_python(42.5) == 42.5
808
+
809
+ def test_number_const_invalid(self, number_const):
810
+ validator = TypeAdapter(number_const)
811
+ with pytest.raises(ValidationError):
812
+ validator.validate_python(42)
813
+
814
+ def test_boolean_const_valid(self, boolean_const):
815
+ validator = TypeAdapter(boolean_const)
816
+ assert validator.validate_python(True) is True
817
+
818
+ def test_boolean_const_invalid(self, boolean_const):
819
+ validator = TypeAdapter(boolean_const)
820
+ with pytest.raises(ValidationError):
821
+ validator.validate_python(False)
822
+
823
+ def test_null_const_valid(self, null_const):
824
+ validator = TypeAdapter(null_const)
825
+ assert validator.validate_python(None) is None
826
+
827
+ def test_null_const_invalid(self, null_const):
828
+ validator = TypeAdapter(null_const)
829
+ with pytest.raises(ValidationError):
830
+ validator.validate_python(False)
831
+
832
+ def test_object_consts_valid(self, object_with_consts):
833
+ validator = TypeAdapter(object_with_consts)
834
+ result = validator.validate_python(
835
+ {"env": "production", "version": 1, "enabled": True}
836
+ )
837
+ assert result.env == "production"
838
+ assert result.version == 1
839
+ assert result.enabled is True
840
+
841
+ def test_object_consts_invalid(self, object_with_consts):
842
+ validator = TypeAdapter(object_with_consts)
843
+ with pytest.raises(ValidationError):
844
+ validator.validate_python(
845
+ {
846
+ "env": "production",
847
+ "version": 2, # Wrong constant
848
+ "enabled": True,
849
+ }
850
+ )
851
+
852
+
853
+ class TestSchemaCaching:
854
+ """Test suite for schema caching behavior."""
855
+
856
+ def test_identical_schemas_reuse_class(self):
857
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
858
+
859
+ class1 = json_schema_to_type(schema)
860
+ class2 = json_schema_to_type(schema)
861
+ assert class1 is class2
862
+
863
+ def test_different_names_different_classes(self):
864
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
865
+
866
+ class1 = json_schema_to_type(schema, name="Class1")
867
+ class2 = json_schema_to_type(schema, name="Class2")
868
+ assert class1 is not class2
869
+ assert class1.__name__ == "Class1"
870
+ assert class2.__name__ == "Class2"
871
+
872
+ def test_nested_schema_caching(self):
873
+ schema = {
874
+ "type": "object",
875
+ "properties": {
876
+ "nested": {"type": "object", "properties": {"name": {"type": "string"}}}
877
+ },
878
+ }
879
+
880
+ class1 = json_schema_to_type(schema)
881
+ class2 = json_schema_to_type(schema)
882
+
883
+ # Both main classes and their nested classes should be identical
884
+ assert class1 is class2
885
+ assert (
886
+ class1.__dataclass_fields__["nested"].type
887
+ is class2.__dataclass_fields__["nested"].type
888
+ )
889
+
890
+
891
+ class TestSchemaHashing:
892
+ """Test suite for schema hashing utility."""
893
+
894
+ def test_deterministic_hash(self):
895
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
896
+ hash1 = _hash_schema(schema)
897
+ hash2 = _hash_schema(schema)
898
+ assert hash1 == hash2
899
+ assert isinstance(hash1, str)
900
+ assert len(hash1) == 64 # SHA-256 hash length
901
+
902
+ def test_different_schemas_different_hashes(self):
903
+ schema1 = {"type": "object", "properties": {"name": {"type": "string"}}}
904
+ schema2 = {"type": "object", "properties": {"age": {"type": "integer"}}}
905
+ assert _hash_schema(schema1) != _hash_schema(schema2)
906
+
907
+ def test_order_independent_hash(self):
908
+ schema1 = {"properties": {"name": {"type": "string"}}, "type": "object"}
909
+ schema2 = {"type": "object", "properties": {"name": {"type": "string"}}}
910
+ assert _hash_schema(schema1) == _hash_schema(schema2)
911
+
912
+ def test_nested_schema_hash(self):
913
+ schema = {
914
+ "type": "object",
915
+ "properties": {
916
+ "nested": {"type": "object", "properties": {"name": {"type": "string"}}}
917
+ },
918
+ }
919
+ hash1 = _hash_schema(schema)
920
+ assert isinstance(hash1, str)
921
+ assert len(hash1) == 64
922
+
923
+
924
+ class TestDefaultMerging:
925
+ """Test suite for default value merging behavior."""
926
+
927
+ def test_simple_merge(self):
928
+ defaults = {"name": "anonymous", "age": 0}
929
+ data = {"name": "test"}
930
+ result = _merge_defaults(data, {"properties": {}}, defaults)
931
+ assert result["name"] == "test"
932
+ assert result["age"] == 0
933
+
934
+ def test_nested_merge(self):
935
+ defaults = {"user": {"name": "anonymous", "settings": {"theme": "light"}}}
936
+ data = {"user": {"name": "test"}}
937
+ result = _merge_defaults(data, {"properties": {}}, defaults)
938
+ assert result["user"]["name"] == "test"
939
+ assert result["user"]["settings"]["theme"] == "light"
940
+
941
+ def test_array_merge(self):
942
+ defaults = {
943
+ "items": [
944
+ {"name": "item1", "done": False},
945
+ {"name": "item2", "done": False},
946
+ ]
947
+ }
948
+ data = {"items": [{"name": "custom", "done": True}]}
949
+ result = _merge_defaults(data, {"properties": {}}, defaults)
950
+ assert len(result["items"]) == 1
951
+ assert result["items"][0]["name"] == "custom"
952
+ assert result["items"][0]["done"] is True
953
+
954
+ def test_empty_data_uses_defaults(self):
955
+ schema = {
956
+ "properties": {
957
+ "user": {
958
+ "type": "object",
959
+ "properties": {
960
+ "name": {"type": "string", "default": "anonymous"},
961
+ "settings": {"type": "object", "default": {"theme": "light"}},
962
+ },
963
+ "default": {"name": "guest", "settings": {"theme": "dark"}},
964
+ }
965
+ }
966
+ }
967
+ result = _merge_defaults({}, schema)
968
+ assert result["user"]["name"] == "guest"
969
+ assert result["user"]["settings"]["theme"] == "dark"
970
+
971
+ def test_property_level_defaults(self):
972
+ schema = {
973
+ "properties": {
974
+ "name": {"type": "string", "default": "anonymous"},
975
+ "age": {"type": "integer", "default": 0},
976
+ }
977
+ }
978
+ result = _merge_defaults({}, schema)
979
+ assert result["name"] == "anonymous"
980
+ assert result["age"] == 0
981
+
982
+ def test_nested_property_defaults(self):
983
+ schema = {
984
+ "properties": {
985
+ "user": {
986
+ "type": "object",
987
+ "properties": {
988
+ "name": {"type": "string", "default": "anonymous"},
989
+ "settings": {
990
+ "type": "object",
991
+ "properties": {
992
+ "theme": {"type": "string", "default": "light"}
993
+ },
994
+ },
995
+ },
996
+ }
997
+ }
998
+ }
999
+ result = _merge_defaults({"user": {"settings": {}}}, schema)
1000
+ assert result["user"]["name"] == "anonymous"
1001
+ assert result["user"]["settings"]["theme"] == "light"
1002
+
1003
+ def test_default_priority(self):
1004
+ schema = {
1005
+ "properties": {
1006
+ "settings": {
1007
+ "type": "object",
1008
+ "properties": {"theme": {"type": "string", "default": "light"}},
1009
+ "default": {"theme": "dark"},
1010
+ }
1011
+ },
1012
+ "default": {"settings": {"theme": "system"}},
1013
+ }
1014
+
1015
+ # Test priority: data > parent default > object default > property default
1016
+ result1 = _merge_defaults({}, schema) # Uses schema default
1017
+ assert result1["settings"]["theme"] == "system"
1018
+
1019
+ result2 = _merge_defaults({"settings": {}}, schema) # Uses object default
1020
+ assert result2["settings"]["theme"] == "dark"
1021
+
1022
+ result3 = _merge_defaults(
1023
+ {"settings": {"theme": "custom"}}, schema
1024
+ ) # Uses provided data
1025
+ assert result3["settings"]["theme"] == "custom"
1026
+
1027
+
1028
+ class TestEdgeCases:
1029
+ """Test suite for edge cases and corner scenarios."""
1030
+
1031
+ def test_empty_schema(self):
1032
+ schema = {}
1033
+ result = json_schema_to_type(schema)
1034
+ assert result is object
1035
+
1036
+ def test_schema_without_type(self):
1037
+ schema = {"properties": {"name": {"type": "string"}}}
1038
+ Type = json_schema_to_type(schema)
1039
+ validator = TypeAdapter(Type)
1040
+ result = validator.validate_python({"name": "test"})
1041
+ assert result.name == "test"
1042
+
1043
+ def test_recursive_defaults(self):
1044
+ schema = {
1045
+ "type": "object",
1046
+ "properties": {
1047
+ "node": {
1048
+ "type": "object",
1049
+ "properties": {"value": {"type": "string"}, "next": {"$ref": "#"}},
1050
+ "default": {"value": "default", "next": None},
1051
+ }
1052
+ },
1053
+ }
1054
+ Type = json_schema_to_type(schema)
1055
+ validator = TypeAdapter(Type)
1056
+ result = validator.validate_python({})
1057
+ assert result.node.value == "default"
1058
+ assert result.node.next is None
1059
+
1060
+ def test_mixed_type_array(self):
1061
+ schema = {
1062
+ "type": "array",
1063
+ "items": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}],
1064
+ }
1065
+ Type = json_schema_to_type(schema)
1066
+ validator = TypeAdapter(Type)
1067
+ result = validator.validate_python(["test", 123, True])
1068
+ assert result == ["test", 123, True]
1069
+
1070
+
1071
+ class TestNameHandling:
1072
+ """Test suite for schema name handling."""
1073
+
1074
+ def test_name_from_title(self):
1075
+ schema = {
1076
+ "type": "object",
1077
+ "title": "Person",
1078
+ "properties": {"name": {"type": "string"}},
1079
+ }
1080
+ Type = json_schema_to_type(schema)
1081
+ assert Type.__name__ == "Person"
1082
+
1083
+ def test_explicit_name_overrides_title(self):
1084
+ schema = {
1085
+ "type": "object",
1086
+ "title": "Person",
1087
+ "properties": {"name": {"type": "string"}},
1088
+ }
1089
+ Type = json_schema_to_type(schema, name="CustomPerson")
1090
+ assert Type.__name__ == "CustomPerson"
1091
+
1092
+ def test_default_name_without_title(self):
1093
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
1094
+ Type = json_schema_to_type(schema)
1095
+ assert Type.__name__ == "Root"
1096
+
1097
+ def test_name_only_allowed_for_objects(self):
1098
+ schema = {"type": "string"}
1099
+ with pytest.raises(ValueError, match="Can not apply name to non-object schema"):
1100
+ json_schema_to_type(schema, name="StringType")
1101
+
1102
+ def test_nested_object_names(self):
1103
+ schema = {
1104
+ "type": "object",
1105
+ "title": "Parent",
1106
+ "properties": {
1107
+ "child": {
1108
+ "type": "object",
1109
+ "title": "Child",
1110
+ "properties": {"name": {"type": "string"}},
1111
+ }
1112
+ },
1113
+ }
1114
+ Type = json_schema_to_type(schema)
1115
+ assert Type.__name__ == "Parent"
1116
+ assert Type.__dataclass_fields__["child"].type.__origin__ is Union
1117
+ assert Type.__dataclass_fields__["child"].type.__args__[0].__name__ == "Child"
1118
+ assert Type.__dataclass_fields__["child"].type.__args__[1] is type(None)
1119
+
1120
+ def test_recursive_schema_naming(self):
1121
+ schema = {
1122
+ "type": "object",
1123
+ "title": "Node",
1124
+ "properties": {"next": {"$ref": "#"}},
1125
+ }
1126
+ Type = json_schema_to_type(schema)
1127
+ assert Type.__name__ == "Node"
1128
+ assert Type.__dataclass_fields__["next"].type.__origin__ is Union
1129
+ assert (
1130
+ Type.__dataclass_fields__["next"].type.__args__[0].__forward_arg__ == "Node"
1131
+ )
1132
+ assert Type.__dataclass_fields__["next"].type.__args__[1] is type(None)
1133
+
1134
+ def test_name_caching_with_different_titles(self):
1135
+ """Ensure schemas with different titles create different cached classes"""
1136
+ schema1 = {
1137
+ "type": "object",
1138
+ "title": "Type1",
1139
+ "properties": {"name": {"type": "string"}},
1140
+ }
1141
+ schema2 = {
1142
+ "type": "object",
1143
+ "title": "Type2",
1144
+ "properties": {"name": {"type": "string"}},
1145
+ }
1146
+ Type1 = json_schema_to_type(schema1)
1147
+ Type2 = json_schema_to_type(schema2)
1148
+ assert Type1 is not Type2
1149
+ assert Type1.__name__ == "Type1"
1150
+ assert Type2.__name__ == "Type2"
1151
+
1152
+ def test_recursive_schema_with_invalid_python_name(self):
1153
+ """Test that recursive schemas work with titles that aren't valid Python identifiers"""
1154
+ schema = {
1155
+ "type": "object",
1156
+ "title": "My Complex Type!",
1157
+ "properties": {"name": {"type": "string"}, "child": {"$ref": "#"}},
1158
+ }
1159
+ Type = json_schema_to_type(schema)
1160
+ # The class should get a sanitized name
1161
+ assert Type.__name__ == "My_Complex_Type"
1162
+ # Create an instance to verify the recursive reference works
1163
+ validator = TypeAdapter(Type)
1164
+ result = validator.validate_python(
1165
+ {"name": "parent", "child": {"name": "child", "child": None}}
1166
+ )
1167
+ assert result.name == "parent"
1168
+ assert result.child.name == "child"
1169
+ assert result.child.child is None
1170
+
1171
+
1172
+ class TestAdditionalProperties:
1173
+ """Test suite for additionalProperties handling."""
1174
+
1175
+ @pytest.fixture
1176
+ def dict_only_schema(self):
1177
+ """Schema with no properties but additionalProperties=True -> dict[str, Any]"""
1178
+ return json_schema_to_type({"type": "object", "additionalProperties": True})
1179
+
1180
+ @pytest.fixture
1181
+ def properties_with_additional(self):
1182
+ """Schema with properties AND additionalProperties=True -> BaseModel"""
1183
+ return json_schema_to_type(
1184
+ {
1185
+ "type": "object",
1186
+ "properties": {
1187
+ "name": {"type": "string"},
1188
+ "age": {"type": "integer"},
1189
+ },
1190
+ "additionalProperties": True,
1191
+ }
1192
+ )
1193
+
1194
+ @pytest.fixture
1195
+ def properties_without_additional(self):
1196
+ """Schema with properties but no additionalProperties -> dataclass"""
1197
+ return json_schema_to_type(
1198
+ {
1199
+ "type": "object",
1200
+ "properties": {
1201
+ "name": {"type": "string"},
1202
+ "age": {"type": "integer"},
1203
+ },
1204
+ }
1205
+ )
1206
+
1207
+ @pytest.fixture
1208
+ def required_properties_with_additional(self):
1209
+ """Schema with required properties AND additionalProperties=True -> BaseModel"""
1210
+ return json_schema_to_type(
1211
+ {
1212
+ "type": "object",
1213
+ "properties": {
1214
+ "name": {"type": "string"},
1215
+ "age": {"type": "integer"},
1216
+ },
1217
+ "required": ["name"],
1218
+ "additionalProperties": True,
1219
+ }
1220
+ )
1221
+
1222
+ def test_dict_only_returns_dict_type(self, dict_only_schema):
1223
+ """Test that schema with no properties + additionalProperties=True returns dict[str, Any]"""
1224
+ import typing
1225
+
1226
+ assert dict_only_schema == dict[str, typing.Any]
1227
+
1228
+ def test_dict_only_accepts_any_data(self, dict_only_schema):
1229
+ """Test that pure dict accepts arbitrary key-value pairs"""
1230
+ validator = TypeAdapter(dict_only_schema)
1231
+ data = {"anything": "works", "numbers": 123, "nested": {"key": "value"}}
1232
+ result = validator.validate_python(data)
1233
+ assert result == data
1234
+ assert isinstance(result, dict)
1235
+
1236
+ def test_properties_with_additional_returns_basemodel(
1237
+ self, properties_with_additional
1238
+ ):
1239
+ """Test that schema with properties + additionalProperties=True returns BaseModel"""
1240
+ assert issubclass(properties_with_additional, BaseModel)
1241
+
1242
+ def test_properties_with_additional_accepts_extra_fields(
1243
+ self, properties_with_additional
1244
+ ):
1245
+ """Test that BaseModel with extra='allow' accepts additional properties"""
1246
+ validator = TypeAdapter(properties_with_additional)
1247
+ data = {
1248
+ "name": "Alice",
1249
+ "age": 30,
1250
+ "extra": "field",
1251
+ "another": {"nested": "data"},
1252
+ }
1253
+ result = validator.validate_python(data)
1254
+
1255
+ # Check standard properties
1256
+ assert result.name == "Alice"
1257
+ assert result.age == 30
1258
+
1259
+ # Check extra properties are preserved with dot access
1260
+ assert hasattr(result, "extra")
1261
+ assert result.extra == "field"
1262
+ assert hasattr(result, "another")
1263
+ assert result.another == {"nested": "data"}
1264
+
1265
+ def test_properties_with_additional_validates_known_fields(
1266
+ self, properties_with_additional
1267
+ ):
1268
+ """Test that BaseModel still validates known fields"""
1269
+ validator = TypeAdapter(properties_with_additional)
1270
+
1271
+ # Should accept valid data
1272
+ result = validator.validate_python({"name": "Alice", "age": 30, "extra": "ok"})
1273
+ assert result.name == "Alice"
1274
+ assert result.age == 30
1275
+ assert result.extra == "ok"
1276
+
1277
+ # Should reject invalid types for known fields
1278
+ with pytest.raises(ValidationError):
1279
+ validator.validate_python({"name": "Alice", "age": "not_a_number"})
1280
+
1281
+ def test_properties_without_additional_is_dataclass(
1282
+ self, properties_without_additional
1283
+ ):
1284
+ """Test that schema with properties but no additionalProperties returns dataclass"""
1285
+ assert not issubclass(properties_without_additional, BaseModel)
1286
+ assert hasattr(properties_without_additional, "__dataclass_fields__")
1287
+
1288
+ def test_properties_without_additional_ignores_extra_fields(
1289
+ self, properties_without_additional
1290
+ ):
1291
+ """Test that dataclass ignores extra properties (current behavior)"""
1292
+ validator = TypeAdapter(properties_without_additional)
1293
+ data = {"name": "Alice", "age": 30, "extra": "ignored"}
1294
+ result = validator.validate_python(data)
1295
+
1296
+ # Check standard properties
1297
+ assert result.name == "Alice"
1298
+ assert result.age == 30
1299
+
1300
+ # Check extra property is ignored
1301
+ assert not hasattr(result, "extra")
1302
+
1303
+ def test_required_properties_with_additional(
1304
+ self, required_properties_with_additional
1305
+ ):
1306
+ """Test BaseModel with required fields and additional properties"""
1307
+ validator = TypeAdapter(required_properties_with_additional)
1308
+
1309
+ # Should accept valid data with required field
1310
+ result = validator.validate_python({"name": "Alice", "extra": "field"})
1311
+ assert result.name == "Alice"
1312
+ assert result.age is None # Optional field
1313
+ assert result.extra == "field"
1314
+
1315
+ # Should reject missing required field
1316
+ with pytest.raises(ValidationError):
1317
+ validator.validate_python({"age": 30, "extra": "field"})
1318
+
1319
+ def test_nested_additional_properties(self):
1320
+ """Test nested objects with additionalProperties"""
1321
+ schema = {
1322
+ "type": "object",
1323
+ "properties": {
1324
+ "user": {
1325
+ "type": "object",
1326
+ "properties": {"name": {"type": "string"}},
1327
+ "additionalProperties": True,
1328
+ },
1329
+ "settings": {
1330
+ "type": "object",
1331
+ "properties": {"theme": {"type": "string"}},
1332
+ },
1333
+ },
1334
+ "additionalProperties": True,
1335
+ }
1336
+
1337
+ Type = json_schema_to_type(schema)
1338
+ validator = TypeAdapter(Type)
1339
+
1340
+ data = {
1341
+ "user": {"name": "Alice", "extra_user_field": "value"},
1342
+ "settings": {"theme": "dark", "extra_settings_field": "ignored"},
1343
+ "top_level_extra": "preserved",
1344
+ }
1345
+
1346
+ result = validator.validate_python(data)
1347
+
1348
+ # Check top-level extra field (BaseModel)
1349
+ assert result.top_level_extra == "preserved"
1350
+
1351
+ # Check nested user extra field (BaseModel)
1352
+ assert result.user.name == "Alice"
1353
+ assert result.user.extra_user_field == "value"
1354
+
1355
+ # Check nested settings - should be dataclass
1356
+ assert result.settings.theme == "dark"
1357
+ # Note: When nested in BaseModel with extra='allow', Pydantic may preserve extra fields
1358
+ # even on dataclass children. The important thing is that settings is still a dataclass.
1359
+ assert not issubclass(type(result.settings), BaseModel)
1360
+
1361
+ def test_additional_properties_false_vs_missing(self):
1362
+ """Test difference between additionalProperties: false and missing additionalProperties"""
1363
+ # Schema with explicit additionalProperties: false
1364
+ schema_false = {
1365
+ "type": "object",
1366
+ "properties": {"name": {"type": "string"}},
1367
+ "additionalProperties": False,
1368
+ }
1369
+
1370
+ # Schema with no additionalProperties key
1371
+ schema_missing = {
1372
+ "type": "object",
1373
+ "properties": {"name": {"type": "string"}},
1374
+ }
1375
+
1376
+ Type_false = json_schema_to_type(schema_false)
1377
+ Type_missing = json_schema_to_type(schema_missing)
1378
+
1379
+ # Both should create dataclasses (not BaseModel)
1380
+ assert not issubclass(Type_false, BaseModel)
1381
+ assert not issubclass(Type_missing, BaseModel)
1382
+ assert hasattr(Type_false, "__dataclass_fields__")
1383
+ assert hasattr(Type_missing, "__dataclass_fields__")
1384
+
1385
+ def test_additional_properties_with_defaults(self):
1386
+ """Test additionalProperties with default values"""
1387
+ schema = {
1388
+ "type": "object",
1389
+ "properties": {
1390
+ "name": {"type": "string", "default": "anonymous"},
1391
+ "age": {"type": "integer", "default": 0},
1392
+ },
1393
+ "additionalProperties": True,
1394
+ }
1395
+
1396
+ Type = json_schema_to_type(schema)
1397
+ validator = TypeAdapter(Type)
1398
+
1399
+ # Test with extra fields and defaults
1400
+ result = validator.validate_python({"extra": "field"})
1401
+ assert result.name == "anonymous"
1402
+ assert result.age == 0
1403
+ assert result.extra == "field"
1404
+
1405
+ def test_additional_properties_type_consistency(self):
1406
+ """Test that the same schema always returns the same type"""
1407
+ schema = {
1408
+ "type": "object",
1409
+ "properties": {"name": {"type": "string"}},
1410
+ "additionalProperties": True,
1411
+ }
1412
+
1413
+ Type1 = json_schema_to_type(schema)
1414
+ Type2 = json_schema_to_type(schema)
1415
+
1416
+ # Should be the same cached class
1417
+ assert Type1 is Type2
1418
+ assert issubclass(Type1, BaseModel)
tests/utilities/test_mcp_config.py CHANGED
@@ -136,8 +136,8 @@ async def test_multi_client(tmp_path: Path):
136
 
137
  result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
138
  result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
139
- assert result_1[0].text == "3" # type: ignore[attr-dict]
140
- assert result_2[0].text == "3" # type: ignore[attr-dict]
141
 
142
 
143
  async def test_remote_config_default_no_auth():
 
136
 
137
  result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
138
  result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
139
+ assert result_1.data == 3
140
+ assert result_2.data == 3
141
 
142
 
143
  async def test_remote_config_default_no_auth():
uv.lock CHANGED
@@ -378,6 +378,28 @@ wheels = [
378
  { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" },
379
  ]
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  [[package]]
382
  name = "exceptiongroup"
383
  version = "1.3.0"
@@ -444,6 +466,7 @@ dependencies = [
444
  { name = "httpx" },
445
  { name = "mcp" },
446
  { name = "openapi-pydantic" },
 
447
  { name = "python-dotenv" },
448
  { name = "rich" },
449
  { name = "typer" },
@@ -484,6 +507,7 @@ requires-dist = [
484
  { name = "httpx", specifier = ">=0.28.1" },
485
  { name = "mcp", specifier = ">=1.10.0" },
486
  { name = "openapi-pydantic", specifier = ">=0.5.1" },
 
487
  { name = "python-dotenv", specifier = ">=1.1.0" },
488
  { name = "rich", specifier = ">=13.9.4" },
489
  { name = "typer", specifier = ">=0.15.2" },
@@ -935,6 +959,11 @@ wheels = [
935
  { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
936
  ]
937
 
 
 
 
 
 
938
  [[package]]
939
  name = "pydantic-core"
940
  version = "2.33.2"
 
378
  { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" },
379
  ]
380
 
381
+ [[package]]
382
+ name = "dnspython"
383
+ version = "2.7.0"
384
+ source = { registry = "https://pypi.org/simple" }
385
+ sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" }
386
+ wheels = [
387
+ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
388
+ ]
389
+
390
+ [[package]]
391
+ name = "email-validator"
392
+ version = "2.2.0"
393
+ source = { registry = "https://pypi.org/simple" }
394
+ dependencies = [
395
+ { name = "dnspython" },
396
+ { name = "idna" },
397
+ ]
398
+ sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" }
399
+ wheels = [
400
+ { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" },
401
+ ]
402
+
403
  [[package]]
404
  name = "exceptiongroup"
405
  version = "1.3.0"
 
466
  { name = "httpx" },
467
  { name = "mcp" },
468
  { name = "openapi-pydantic" },
469
+ { name = "pydantic", extra = ["email"] },
470
  { name = "python-dotenv" },
471
  { name = "rich" },
472
  { name = "typer" },
 
507
  { name = "httpx", specifier = ">=0.28.1" },
508
  { name = "mcp", specifier = ">=1.10.0" },
509
  { name = "openapi-pydantic", specifier = ">=0.5.1" },
510
+ { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
511
  { name = "python-dotenv", specifier = ">=1.1.0" },
512
  { name = "rich", specifier = ">=13.9.4" },
513
  { name = "typer", specifier = ">=0.15.2" },
 
959
  { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
960
  ]
961
 
962
+ [package.optional-dependencies]
963
+ email = [
964
+ { name = "email-validator" },
965
+ ]
966
+
967
  [[package]]
968
  name = "pydantic-core"
969
  version = "2.33.2"