fastmcp 2.2.7__py3-none-any.whl → 2.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/client/client.py CHANGED
@@ -286,7 +286,7 @@ class Client:
286
286
 
287
287
  async def get_prompt(
288
288
  self, name: str, arguments: dict[str, str] | None = None
289
- ) -> list[mcp.types.PromptMessage]:
289
+ ) -> mcp.types.GetPromptResult:
290
290
  """Retrieve a rendered prompt message list from the server.
291
291
 
292
292
  Args:
@@ -294,13 +294,14 @@ class Client:
294
294
  arguments (dict[str, str] | None, optional): Arguments to pass to the prompt. Defaults to None.
295
295
 
296
296
  Returns:
297
- list[mcp.types.PromptMessage]: A list of prompt messages.
297
+ mcp.types.GetPromptResult: The complete response object from the protocol,
298
+ containing the prompt messages and any additional metadata.
298
299
 
299
300
  Raises:
300
301
  RuntimeError: If called while the client is not connected.
301
302
  """
302
303
  result = await self.get_prompt_mcp(name=name, arguments=arguments)
303
- return result.messages
304
+ return result
304
305
 
305
306
  # --- Completion ---
306
307
 
@@ -1,4 +1,9 @@
1
- from .prompt import Prompt, Message, UserMessage, AssistantMessage
1
+ from .prompt import Prompt, PromptMessage, Message
2
2
  from .prompt_manager import PromptManager
3
3
 
4
- __all__ = ["Prompt", "PromptManager", "Message", "UserMessage", "AssistantMessage"]
4
+ __all__ = [
5
+ "Prompt",
6
+ "PromptManager",
7
+ "PromptMessage",
8
+ "Message",
9
+ ]
fastmcp/prompts/prompt.py CHANGED
@@ -4,17 +4,19 @@ from __future__ import annotations as _annotations
4
4
 
5
5
  import inspect
6
6
  from collections.abc import Awaitable, Callable, Sequence
7
- from typing import TYPE_CHECKING, Annotated, Any, Literal
7
+ from typing import TYPE_CHECKING, Annotated, Any
8
8
 
9
9
  import pydantic_core
10
- from mcp.types import EmbeddedResource, ImageContent, TextContent
10
+ from mcp.types import EmbeddedResource, ImageContent, PromptMessage, Role, TextContent
11
11
  from mcp.types import Prompt as MCPPrompt
12
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
14
 
15
+ from fastmcp.utilities.json_schema import prune_params
15
16
  from fastmcp.utilities.types import (
16
17
  _convert_set_defaults,
17
- is_class_member_of_type,
18
+ find_kwarg_by_type,
19
+ get_cached_typeadapter,
18
20
  )
19
21
 
20
22
  if TYPE_CHECKING:
@@ -26,32 +28,24 @@ if TYPE_CHECKING:
26
28
  CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
27
29
 
28
30
 
29
- class Message(BaseModel):
30
- """Base class for all prompt messages."""
31
+ def Message(
32
+ content: str | CONTENT_TYPES, role: Role | None = None, **kwargs: Any
33
+ ) -> PromptMessage:
34
+ """A user-friendly constructor for PromptMessage."""
35
+ if isinstance(content, str):
36
+ content = TextContent(type="text", text=content)
37
+ if role is None:
38
+ role = "user"
39
+ return PromptMessage(content=content, role=role, **kwargs)
31
40
 
32
- role: Literal["user", "assistant"]
33
- content: CONTENT_TYPES
34
41
 
35
- def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any):
36
- if isinstance(content, str):
37
- content = TextContent(type="text", text=content)
38
- super().__init__(content=content, **kwargs)
39
-
40
-
41
- def UserMessage(content: str | CONTENT_TYPES, **kwargs: Any) -> Message:
42
- """A message from the user."""
43
- return Message(content=content, role="user", **kwargs)
44
-
45
-
46
- def AssistantMessage(content: str | CONTENT_TYPES, **kwargs: Any) -> Message:
47
- """A message from the assistant."""
48
- return Message(content=content, role="assistant", **kwargs)
49
-
50
-
51
- message_validator = TypeAdapter[Message](Message)
42
+ message_validator = TypeAdapter[PromptMessage](PromptMessage)
52
43
 
53
44
  SyncPromptResult = (
54
- str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
45
+ str
46
+ | PromptMessage
47
+ | dict[str, Any]
48
+ | Sequence[str | PromptMessage | dict[str, Any]]
55
49
  )
56
50
  PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
57
51
 
@@ -109,35 +103,32 @@ class Prompt(BaseModel):
109
103
 
110
104
  if func_name == "<lambda>":
111
105
  raise ValueError("You must provide a name for lambda functions")
106
+ # Reject functions with *args or **kwargs
107
+ sig = inspect.signature(fn)
108
+ for param in sig.parameters.values():
109
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
110
+ raise ValueError("Functions with *args are not supported as prompts")
111
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
112
+ raise ValueError("Functions with **kwargs are not supported as prompts")
113
+
114
+ type_adapter = get_cached_typeadapter(fn)
115
+ parameters = type_adapter.json_schema()
112
116
 
113
117
  # Auto-detect context parameter if not provided
114
118
  if context_kwarg is None:
115
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
116
- sig = inspect.signature(fn.__func__)
117
- else:
118
- sig = inspect.signature(fn)
119
- for param_name, param in sig.parameters.items():
120
- if is_class_member_of_type(param.annotation, Context):
121
- context_kwarg = param_name
122
- break
123
-
124
- # Get schema from TypeAdapter - will fail if function isn't properly typed
125
- parameters = TypeAdapter(fn).json_schema()
119
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
120
+ if context_kwarg:
121
+ parameters = prune_params(parameters, params=[context_kwarg])
126
122
 
127
123
  # Convert parameters to PromptArguments
128
124
  arguments: list[PromptArgument] = []
129
125
  if "properties" in parameters:
130
126
  for param_name, param in parameters["properties"].items():
131
- # Skip context parameter
132
- if param_name == context_kwarg:
133
- continue
134
-
135
- required = param_name in parameters.get("required", [])
136
127
  arguments.append(
137
128
  PromptArgument(
138
129
  name=param_name,
139
130
  description=param.get("description"),
140
- required=required,
131
+ required=param_name in parameters.get("required", []),
141
132
  )
142
133
  )
143
134
 
@@ -157,7 +148,7 @@ class Prompt(BaseModel):
157
148
  self,
158
149
  arguments: dict[str, Any] | None = None,
159
150
  context: Context[ServerSessionT, LifespanContextT] | None = None,
160
- ) -> list[Message]:
151
+ ) -> list[PromptMessage]:
161
152
  """Render the prompt with arguments."""
162
153
  # Validate required arguments
163
154
  if self.arguments:
@@ -183,21 +174,28 @@ class Prompt(BaseModel):
183
174
  result = [result]
184
175
 
185
176
  # Convert result to messages
186
- messages: list[Message] = []
187
- for msg in result: # type: ignore[reportUnknownVariableType]
177
+ messages: list[PromptMessage] = []
178
+ for msg in result:
188
179
  try:
189
- if isinstance(msg, Message):
180
+ if isinstance(msg, PromptMessage):
190
181
  messages.append(msg)
191
- elif isinstance(msg, dict):
192
- messages.append(message_validator.validate_python(msg))
193
182
  elif isinstance(msg, str):
194
- content = TextContent(type="text", text=msg)
195
- messages.append(Message(role="user", content=content))
183
+ messages.append(
184
+ PromptMessage(
185
+ role="user",
186
+ content=TextContent(type="text", text=msg),
187
+ )
188
+ )
196
189
  else:
197
190
  content = pydantic_core.to_json(
198
191
  msg, fallback=str, indent=2
199
192
  ).decode()
200
- messages.append(Message(role="user", content=content))
193
+ messages.append(
194
+ PromptMessage(
195
+ role="user",
196
+ content=TextContent(type="text", text=content),
197
+ )
198
+ )
201
199
  except Exception:
202
200
  raise ValueError(
203
201
  f"Could not convert prompt result to message: {msg}"
@@ -5,8 +5,10 @@ from __future__ import annotations as _annotations
5
5
  from collections.abc import Awaitable, Callable
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
+ from mcp import GetPromptResult
9
+
8
10
  from fastmcp.exceptions import NotFoundError
9
- from fastmcp.prompts.prompt import Message, Prompt, PromptResult
11
+ from fastmcp.prompts.prompt import Prompt, PromptResult
10
12
  from fastmcp.settings import DuplicateBehavior
11
13
  from fastmcp.utilities.logging import get_logger
12
14
 
@@ -81,13 +83,18 @@ class PromptManager:
81
83
  name: str,
82
84
  arguments: dict[str, Any] | None = None,
83
85
  context: Context[ServerSessionT, LifespanContextT] | None = None,
84
- ) -> list[Message]:
86
+ ) -> GetPromptResult:
85
87
  """Render a prompt by name with arguments."""
86
88
  prompt = self.get_prompt(name)
87
89
  if not prompt:
88
90
  raise NotFoundError(f"Unknown prompt: {name}")
89
91
 
90
- return await prompt.render(arguments, context=context)
92
+ messages = await prompt.render(arguments, context=context)
93
+
94
+ return GetPromptResult(
95
+ description=prompt.description,
96
+ messages=messages,
97
+ )
91
98
 
92
99
  def has_prompt(self, key: str) -> bool:
93
100
  """Check if a prompt exists."""
@@ -22,7 +22,7 @@ from pydantic import (
22
22
  from fastmcp.resources.types import FunctionResource, Resource
23
23
  from fastmcp.utilities.types import (
24
24
  _convert_set_defaults,
25
- is_class_member_of_type,
25
+ find_kwarg_by_type,
26
26
  )
27
27
 
28
28
  if TYPE_CHECKING:
@@ -109,23 +109,25 @@ class ResourceTemplate(BaseModel):
109
109
  if func_name == "<lambda>":
110
110
  raise ValueError("You must provide a name for lambda functions")
111
111
 
112
+ # Reject functions with *args
113
+ # (**kwargs is allowed because the URI will define the parameter names)
114
+ sig = inspect.signature(fn)
115
+ for param in sig.parameters.values():
116
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
117
+ raise ValueError(
118
+ "Functions with *args are not supported as resource templates"
119
+ )
120
+
112
121
  # Auto-detect context parameter if not provided
113
122
  if context_kwarg is None:
114
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
115
- sig = inspect.signature(fn.__func__)
116
- else:
117
- sig = inspect.signature(fn)
118
- for param_name, param in sig.parameters.items():
119
- if is_class_member_of_type(param.annotation, Context):
120
- context_kwarg = param_name
121
- break
123
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
122
124
 
123
125
  # Validate that URI params match function params
124
126
  uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
125
127
  if not uri_params:
126
128
  raise ValueError("URI template must contain at least one parameter")
127
129
 
128
- func_params = set(inspect.signature(fn).parameters.keys())
130
+ func_params = set(sig.parameters.keys())
129
131
  if context_kwarg:
130
132
  func_params.discard(context_kwarg)
131
133
 
@@ -133,20 +135,26 @@ class ResourceTemplate(BaseModel):
133
135
  required_params = {
134
136
  p
135
137
  for p in func_params
136
- if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
138
+ if sig.parameters[p].default is inspect.Parameter.empty
139
+ and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
140
+ and p != context_kwarg
137
141
  }
138
- if context_kwarg and context_kwarg in required_params:
139
- required_params.discard(context_kwarg)
140
142
 
143
+ # Check if required parameters are a subset of the URI parameters
141
144
  if not required_params.issubset(uri_params):
142
145
  raise ValueError(
143
- f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
146
+ f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
144
147
  )
145
148
 
146
- if not uri_params.issubset(func_params):
147
- raise ValueError(
148
- f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
149
- )
149
+ # Check if the URI parameters are a subset of the function parameters (skip if **kwargs present)
150
+ if not any(
151
+ param.kind == inspect.Parameter.VAR_KEYWORD
152
+ for param in sig.parameters.values()
153
+ ):
154
+ if not uri_params.issubset(func_params):
155
+ raise ValueError(
156
+ f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
157
+ )
150
158
 
151
159
  # Get schema from TypeAdapter - will fail if function isn't properly typed
152
160
  parameters = TypeAdapter(fn).json_schema()
fastmcp/server/openapi.py CHANGED
@@ -18,7 +18,6 @@ from fastmcp.resources import Resource, ResourceTemplate
18
18
  from fastmcp.server.server import FastMCP
19
19
  from fastmcp.tools.tool import Tool, _convert_to_content
20
20
  from fastmcp.utilities import openapi
21
- from fastmcp.utilities.func_metadata import func_metadata
22
21
  from fastmcp.utilities.logging import get_logger
23
22
  from fastmcp.utilities.openapi import (
24
23
  _combine_schemas,
@@ -123,8 +122,6 @@ class OpenAPITool(Tool):
123
122
  name: str,
124
123
  description: str,
125
124
  parameters: dict[str, Any],
126
- fn_metadata: Any,
127
- is_async: bool = True,
128
125
  tags: set[str] = set(),
129
126
  timeout: float | None = None,
130
127
  annotations: ToolAnnotations | None = None,
@@ -135,8 +132,6 @@ class OpenAPITool(Tool):
135
132
  description=description,
136
133
  parameters=parameters,
137
134
  fn=self._execute_request, # We'll use an instance method instead of a global function
138
- fn_metadata=fn_metadata,
139
- is_async=is_async,
140
135
  context_kwarg="context", # Default context keyword argument
141
136
  tags=tags,
142
137
  annotations=annotations,
@@ -553,8 +548,6 @@ class FastMCPOpenAPI(FastMCP):
553
548
  name=tool_name,
554
549
  description=enhanced_description,
555
550
  parameters=combined_schema,
556
- fn_metadata=func_metadata(_openapi_passthrough),
557
- is_async=True,
558
551
  tags=set(route.tags or []),
559
552
  timeout=self._timeout,
560
553
  )
fastmcp/server/proxy.py CHANGED
@@ -19,12 +19,11 @@ from pydantic.networks import AnyUrl
19
19
 
20
20
  from fastmcp.client import Client
21
21
  from fastmcp.exceptions import NotFoundError
22
- from fastmcp.prompts import Message, Prompt
22
+ from fastmcp.prompts import Prompt, PromptMessage
23
23
  from fastmcp.resources import Resource, ResourceTemplate
24
24
  from fastmcp.server.context import Context
25
25
  from fastmcp.server.server import FastMCP
26
26
  from fastmcp.tools.tool import Tool
27
- from fastmcp.utilities.func_metadata import func_metadata
28
27
  from fastmcp.utilities.logging import get_logger
29
28
 
30
29
  if TYPE_CHECKING:
@@ -53,8 +52,6 @@ class ProxyTool(Tool):
53
52
  description=tool.description,
54
53
  parameters=tool.inputSchema,
55
54
  fn=_proxy_passthrough,
56
- fn_metadata=func_metadata(_proxy_passthrough),
57
- is_async=True,
58
55
  )
59
56
 
60
57
  async def run(
@@ -178,10 +175,10 @@ class ProxyPrompt(Prompt):
178
175
  self,
179
176
  arguments: dict[str, Any],
180
177
  context: Context[ServerSessionT, LifespanContextT] | None = None,
181
- ) -> list[Message]:
178
+ ) -> list[PromptMessage]:
182
179
  async with self._client:
183
180
  result = await self._client.get_prompt(self.name, arguments)
184
- return [Message(role=m.role, content=m.content) for m in result]
181
+ return result.messages
185
182
 
186
183
 
187
184
  class FastMCPProxy(FastMCP):
@@ -294,4 +291,4 @@ class FastMCPProxy(FastMCP):
294
291
  except NotFoundError:
295
292
  async with self.client:
296
293
  result = await self.client.get_prompt(name, arguments)
297
- return GetPromptResult(messages=result)
294
+ return result
fastmcp/server/server.py CHANGED
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
14
14
 
15
15
  import anyio
16
16
  import httpx
17
+ import pydantic
17
18
  import uvicorn
18
19
  from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
19
20
  from mcp.server.auth.middleware.bearer_auth import (
@@ -32,7 +33,6 @@ from mcp.types import (
32
33
  EmbeddedResource,
33
34
  GetPromptResult,
34
35
  ImageContent,
35
- PromptMessage,
36
36
  TextContent,
37
37
  ToolAnnotations,
38
38
  )
@@ -40,7 +40,7 @@ from mcp.types import Prompt as MCPPrompt
40
40
  from mcp.types import Resource as MCPResource
41
41
  from mcp.types import ResourceTemplate as MCPResourceTemplate
42
42
  from mcp.types import Tool as MCPTool
43
- from pydantic.networks import AnyUrl
43
+ from pydantic import AnyUrl
44
44
  from starlette.applications import Starlette
45
45
  from starlette.middleware import Middleware
46
46
  from starlette.middleware.authentication import AuthenticationMiddleware
@@ -89,6 +89,8 @@ class MountedServer:
89
89
  if prompt_separator is None:
90
90
  prompt_separator = "_"
91
91
 
92
+ _validate_resource_prefix(f"{prefix}{resource_separator}")
93
+
92
94
  self.server = server
93
95
  self.prefix = prefix
94
96
  self.tool_separator = tool_separator
@@ -504,15 +506,10 @@ class FastMCP(Generic[LifespanResultT]):
504
506
  """
505
507
  if self._prompt_manager.has_prompt(name):
506
508
  context = self.get_context()
507
- messages = await self._prompt_manager.render_prompt(
509
+ prompt_result = await self._prompt_manager.render_prompt(
508
510
  name, arguments=arguments or {}, context=context
509
511
  )
510
-
511
- return GetPromptResult(
512
- messages=[
513
- PromptMessage(role=m.role, content=m.content) for m in messages
514
- ]
515
- )
512
+ return prompt_result
516
513
  else:
517
514
  for server in self._mounted_servers.values():
518
515
  if server.match_prompt(name):
@@ -826,16 +823,21 @@ class FastMCP(Generic[LifespanResultT]):
826
823
  host: str | None = None,
827
824
  port: int | None = None,
828
825
  log_level: str | None = None,
826
+ uvicorn_config: dict | None = None,
829
827
  ) -> None:
830
828
  """Run the server using SSE transport."""
831
- app = self.sse_app()
832
- app = RequestMiddleware(app)
829
+ uvicorn_config = uvicorn_config or {}
830
+ # the SSE app hangs even when a signal is sent, so we disable the timeout to make it possible to close immediately.
831
+ # see https://github.com/jlowin/fastmcp/issues/296
832
+ uvicorn_config.setdefault("timeout_graceful_shutdown", 0)
833
+ app = RequestMiddleware(self.sse_app())
833
834
 
834
835
  config = uvicorn.Config(
835
836
  app,
836
837
  host=host or self.settings.host,
837
838
  port=port or self.settings.port,
838
839
  log_level=log_level or self.settings.log_level.lower(),
840
+ **uvicorn_config,
839
841
  )
840
842
  server = uvicorn.Server(config)
841
843
  await server.serve()
@@ -1075,6 +1077,7 @@ class FastMCP(Generic[LifespanResultT]):
1075
1077
 
1076
1078
  # Import resources and templates from the mounted server
1077
1079
  resource_prefix = f"{prefix}{resource_separator}"
1080
+ _validate_resource_prefix(resource_prefix)
1078
1081
  for key, resource in (await server.get_resources()).items():
1079
1082
  self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
1080
1083
  for key, template in (await server.get_resource_templates()).items():
@@ -1132,3 +1135,13 @@ class FastMCP(Generic[LifespanResultT]):
1132
1135
  from fastmcp.server.proxy import FastMCPProxy
1133
1136
 
1134
1137
  return FastMCPProxy(client=client, **settings)
1138
+
1139
+
1140
+ def _validate_resource_prefix(prefix: str) -> None:
1141
+ valid_resource = "resource://path/to/resource"
1142
+ try:
1143
+ AnyUrl(f"{prefix}{valid_resource}")
1144
+ except pydantic.ValidationError as e:
1145
+ raise ValueError(
1146
+ f"Resource prefix or separator would result in an invalid resource URI: {e}"
1147
+ )
fastmcp/tools/tool.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
+ import json
4
5
  from collections.abc import Callable
5
6
  from typing import TYPE_CHECKING, Annotated, Any
6
7
 
@@ -10,11 +11,13 @@ from mcp.types import Tool as MCPTool
10
11
  from pydantic import BaseModel, BeforeValidator, Field
11
12
 
12
13
  from fastmcp.exceptions import ToolError
13
- from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
14
+ from fastmcp.utilities.json_schema import prune_params
15
+ from fastmcp.utilities.logging import get_logger
14
16
  from fastmcp.utilities.types import (
15
17
  Image,
16
18
  _convert_set_defaults,
17
- is_class_member_of_type,
19
+ find_kwarg_by_type,
20
+ get_cached_typeadapter,
18
21
  )
19
22
 
20
23
  if TYPE_CHECKING:
@@ -23,6 +26,12 @@ if TYPE_CHECKING:
23
26
 
24
27
  from fastmcp.server import Context
25
28
 
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ def default_serializer(data: Any) -> str:
33
+ return pydantic_core.to_json(data, fallback=str, indent=2).decode()
34
+
26
35
 
27
36
  class Tool(BaseModel):
28
37
  """Internal tool registration info."""
@@ -31,11 +40,6 @@ class Tool(BaseModel):
31
40
  name: str = Field(description="Name of the tool")
32
41
  description: str = Field(description="Description of what the tool does")
33
42
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
34
- fn_metadata: FuncMetadata = Field(
35
- description="Metadata about the function including a pydantic model for tool"
36
- " arguments"
37
- )
38
- is_async: bool = Field(description="Whether the tool is async")
39
43
  context_kwarg: str | None = Field(
40
44
  None, description="Name of the kwarg that should receive context"
41
45
  )
@@ -63,44 +67,34 @@ class Tool(BaseModel):
63
67
  """Create a Tool from a function."""
64
68
  from fastmcp import Context
65
69
 
70
+ # Reject functions with *args or **kwargs
71
+ sig = inspect.signature(fn)
72
+ for param in sig.parameters.values():
73
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
74
+ raise ValueError("Functions with *args are not supported as tools")
75
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
76
+ raise ValueError("Functions with **kwargs are not supported as tools")
77
+
66
78
  func_name = name or fn.__name__
67
79
 
68
80
  if func_name == "<lambda>":
69
81
  raise ValueError("You must provide a name for lambda functions")
70
82
 
71
83
  func_doc = description or fn.__doc__ or ""
72
- is_async = inspect.iscoroutinefunction(fn)
84
+
85
+ type_adapter = get_cached_typeadapter(fn)
86
+ schema = type_adapter.json_schema()
73
87
 
74
88
  if context_kwarg is None:
75
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
76
- sig = inspect.signature(fn.__func__)
77
- else:
78
- sig = inspect.signature(fn)
79
- for param_name, param in sig.parameters.items():
80
- if is_class_member_of_type(param.annotation, Context):
81
- context_kwarg = param_name
82
- break
83
-
84
- # Use callable typing to ensure fn is treated as a callable despite being a classmethod
85
- fn_callable: Callable[..., Any] = fn
86
- func_arg_metadata = func_metadata(
87
- fn_callable,
88
- skip_names=[context_kwarg] if context_kwarg is not None else [],
89
- )
90
- try:
91
- parameters = func_arg_metadata.arg_model.model_json_schema()
92
- except Exception as e:
93
- raise TypeError(
94
- f'Unable to parse parameters for function "{fn.__name__}": {e}'
95
- ) from e
89
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
90
+ if context_kwarg:
91
+ schema = prune_params(schema, params=[context_kwarg])
96
92
 
97
93
  return cls(
98
- fn=fn_callable,
94
+ fn=fn,
99
95
  name=func_name,
100
96
  description=func_doc,
101
- parameters=parameters,
102
- fn_metadata=func_arg_metadata,
103
- is_async=is_async,
97
+ parameters=schema,
104
98
  context_kwarg=context_kwarg,
105
99
  tags=tags or set(),
106
100
  annotations=annotations,
@@ -114,17 +108,32 @@ class Tool(BaseModel):
114
108
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
115
109
  """Run the tool with arguments."""
116
110
  try:
117
- pass_args = (
118
- {self.context_kwarg: context}
119
- if self.context_kwarg is not None
120
- else None
111
+ injected_args = (
112
+ {self.context_kwarg: context} if self.context_kwarg is not None else {}
121
113
  )
122
- result = await self.fn_metadata.call_fn_with_arg_validation(
123
- fn=self.fn,
124
- fn_is_async=self.is_async,
125
- arguments_to_validate=arguments,
126
- arguments_to_pass_directly=pass_args,
114
+
115
+ parsed_args = arguments.copy()
116
+
117
+ # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
118
+ # being passed in as JSON inside a string rather than an actual list.
119
+ #
120
+ # Claude desktop is prone to this - in fact it seems incapable of NOT doing
121
+ # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
122
+ # which can be pre-parsed here.
123
+ for param_name in self.parameters["properties"]:
124
+ if isinstance(parsed_args.get(param_name, None), str):
125
+ try:
126
+ parsed_args[param_name] = json.loads(parsed_args[param_name])
127
+ except json.JSONDecodeError:
128
+ pass
129
+
130
+ type_adapter = get_cached_typeadapter(
131
+ self.fn, config=frozenset([("coerce_numbers_to_str", True)])
127
132
  )
133
+ result = type_adapter.validate_python(parsed_args | injected_args)
134
+ if inspect.isawaitable(result):
135
+ result = await result
136
+
128
137
  return _convert_to_content(result, serializer=self.serializer)
129
138
  except Exception as e:
130
139
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
@@ -182,9 +191,17 @@ def _convert_to_content(
182
191
  return other_content + mcp_types
183
192
 
184
193
  if not isinstance(result, str):
185
- if serializer is not None:
186
- result = serializer(result)
194
+ if serializer is None:
195
+ result = default_serializer(result)
187
196
  else:
188
- result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
197
+ try:
198
+ result = serializer(result)
199
+ except Exception as e:
200
+ logger.warning(
201
+ "Error serializing tool result: %s",
202
+ e,
203
+ exc_info=True,
204
+ )
205
+ result = default_serializer(result)
189
206
 
190
207
  return [TextContent(type="text", text=result)]