fastmcp 2.2.7__py3-none-any.whl → 2.2.8__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 +4 -3
- fastmcp/prompts/__init__.py +7 -2
- fastmcp/prompts/prompt.py +49 -51
- fastmcp/prompts/prompt_manager.py +10 -3
- fastmcp/resources/template.py +26 -18
- fastmcp/server/openapi.py +0 -7
- fastmcp/server/proxy.py +4 -7
- fastmcp/server/server.py +9 -10
- fastmcp/tools/tool.py +61 -46
- fastmcp/utilities/json_schema.py +59 -0
- fastmcp/utilities/types.py +38 -1
- fastmcp-2.2.8.dist-info/METADATA +407 -0
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.8.dist-info}/RECORD +16 -16
- fastmcp/utilities/func_metadata.py +0 -229
- fastmcp-2.2.7.dist-info/METADATA +0 -810
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.8.dist-info}/WHEEL +0 -0
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.8.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.8.dist-info}/licenses/LICENSE +0 -0
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
|
-
) ->
|
|
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
|
-
|
|
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
|
|
304
|
+
return result
|
|
304
305
|
|
|
305
306
|
# --- Completion ---
|
|
306
307
|
|
fastmcp/prompts/__init__.py
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
from .prompt import Prompt,
|
|
1
|
+
from .prompt import Prompt, PromptMessage, Message
|
|
2
2
|
from .prompt_manager import PromptManager
|
|
3
3
|
|
|
4
|
-
__all__ = [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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[
|
|
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[
|
|
187
|
-
for msg in result:
|
|
177
|
+
messages: list[PromptMessage] = []
|
|
178
|
+
for msg in result:
|
|
188
179
|
try:
|
|
189
|
-
if isinstance(msg,
|
|
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
|
-
|
|
195
|
-
|
|
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(
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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."""
|
fastmcp/resources/template.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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"
|
|
146
|
+
f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
|
|
144
147
|
)
|
|
145
148
|
|
|
146
|
-
if
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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[
|
|
178
|
+
) -> list[PromptMessage]:
|
|
182
179
|
async with self._client:
|
|
183
180
|
result = await self._client.get_prompt(self.name, arguments)
|
|
184
|
-
return
|
|
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
|
|
294
|
+
return result
|
fastmcp/server/server.py
CHANGED
|
@@ -32,7 +32,6 @@ from mcp.types import (
|
|
|
32
32
|
EmbeddedResource,
|
|
33
33
|
GetPromptResult,
|
|
34
34
|
ImageContent,
|
|
35
|
-
PromptMessage,
|
|
36
35
|
TextContent,
|
|
37
36
|
ToolAnnotations,
|
|
38
37
|
)
|
|
@@ -504,15 +503,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
504
503
|
"""
|
|
505
504
|
if self._prompt_manager.has_prompt(name):
|
|
506
505
|
context = self.get_context()
|
|
507
|
-
|
|
506
|
+
prompt_result = await self._prompt_manager.render_prompt(
|
|
508
507
|
name, arguments=arguments or {}, context=context
|
|
509
508
|
)
|
|
510
|
-
|
|
511
|
-
return GetPromptResult(
|
|
512
|
-
messages=[
|
|
513
|
-
PromptMessage(role=m.role, content=m.content) for m in messages
|
|
514
|
-
]
|
|
515
|
-
)
|
|
509
|
+
return prompt_result
|
|
516
510
|
else:
|
|
517
511
|
for server in self._mounted_servers.values():
|
|
518
512
|
if server.match_prompt(name):
|
|
@@ -826,16 +820,21 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
826
820
|
host: str | None = None,
|
|
827
821
|
port: int | None = None,
|
|
828
822
|
log_level: str | None = None,
|
|
823
|
+
uvicorn_config: dict | None = None,
|
|
829
824
|
) -> None:
|
|
830
825
|
"""Run the server using SSE transport."""
|
|
831
|
-
|
|
832
|
-
app
|
|
826
|
+
uvicorn_config = uvicorn_config or {}
|
|
827
|
+
# the SSE app hangs even when a signal is sent, so we disable the timeout to make it possible to close immediately.
|
|
828
|
+
# see https://github.com/jlowin/fastmcp/issues/296
|
|
829
|
+
uvicorn_config.setdefault("timeout_graceful_shutdown", 0)
|
|
830
|
+
app = RequestMiddleware(self.sse_app())
|
|
833
831
|
|
|
834
832
|
config = uvicorn.Config(
|
|
835
833
|
app,
|
|
836
834
|
host=host or self.settings.host,
|
|
837
835
|
port=port or self.settings.port,
|
|
838
836
|
log_level=log_level or self.settings.log_level.lower(),
|
|
837
|
+
**uvicorn_config,
|
|
839
838
|
)
|
|
840
839
|
server = uvicorn.Server(config)
|
|
841
840
|
await server.serve()
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
|
|
85
|
+
type_adapter = get_cached_typeadapter(fn)
|
|
86
|
+
schema = type_adapter.json_schema()
|
|
73
87
|
|
|
74
88
|
if context_kwarg is None:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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=
|
|
94
|
+
fn=fn,
|
|
99
95
|
name=func_name,
|
|
100
96
|
description=func_doc,
|
|
101
|
-
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,30 @@ class Tool(BaseModel):
|
|
|
114
108
|
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
115
109
|
"""Run the tool with arguments."""
|
|
116
110
|
try:
|
|
117
|
-
|
|
118
|
-
{self.context_kwarg: context}
|
|
119
|
-
if self.context_kwarg is not None
|
|
120
|
-
else None
|
|
121
|
-
)
|
|
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,
|
|
111
|
+
injected_args = (
|
|
112
|
+
{self.context_kwarg: context} if self.context_kwarg is not None else {}
|
|
127
113
|
)
|
|
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(self.fn)
|
|
131
|
+
result = type_adapter.validate_python(parsed_args | injected_args)
|
|
132
|
+
if inspect.isawaitable(result):
|
|
133
|
+
result = await result
|
|
134
|
+
|
|
128
135
|
return _convert_to_content(result, serializer=self.serializer)
|
|
129
136
|
except Exception as e:
|
|
130
137
|
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
|
@@ -182,9 +189,17 @@ def _convert_to_content(
|
|
|
182
189
|
return other_content + mcp_types
|
|
183
190
|
|
|
184
191
|
if not isinstance(result, str):
|
|
185
|
-
if serializer is
|
|
186
|
-
result =
|
|
192
|
+
if serializer is None:
|
|
193
|
+
result = default_serializer(result)
|
|
187
194
|
else:
|
|
188
|
-
|
|
195
|
+
try:
|
|
196
|
+
result = serializer(result)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.warning(
|
|
199
|
+
"Error serializing tool result: %s",
|
|
200
|
+
e,
|
|
201
|
+
exc_info=True,
|
|
202
|
+
)
|
|
203
|
+
result = default_serializer(result)
|
|
189
204
|
|
|
190
205
|
return [TextContent(type="text", text=result)]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _prune_param(schema: dict, param: str) -> dict:
|
|
8
|
+
"""Return a new schema with *param* removed from `properties`, `required`,
|
|
9
|
+
and (if no longer referenced) `$defs`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# ── 1. drop from properties/required ──────────────────────────────
|
|
13
|
+
props = schema.get("properties", {})
|
|
14
|
+
removed = props.pop(param, None)
|
|
15
|
+
if removed is None: # nothing to do
|
|
16
|
+
return schema
|
|
17
|
+
# Keep empty properties object rather than removing it entirely
|
|
18
|
+
schema["properties"] = props
|
|
19
|
+
if param in schema.get("required", []):
|
|
20
|
+
schema["required"].remove(param)
|
|
21
|
+
if not schema["required"]:
|
|
22
|
+
schema.pop("required")
|
|
23
|
+
|
|
24
|
+
# ── 2. collect all remaining local $ref targets ───────────────────
|
|
25
|
+
used_defs: set[str] = set()
|
|
26
|
+
|
|
27
|
+
def walk(node: object) -> None: # depth-first traversal
|
|
28
|
+
if isinstance(node, Mapping):
|
|
29
|
+
ref = node.get("$ref")
|
|
30
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
31
|
+
used_defs.add(ref.split("/")[-1])
|
|
32
|
+
for v in node.values():
|
|
33
|
+
walk(v)
|
|
34
|
+
elif isinstance(node, Sequence) and not isinstance(node, str | bytes):
|
|
35
|
+
for v in node:
|
|
36
|
+
walk(v)
|
|
37
|
+
|
|
38
|
+
walk(schema)
|
|
39
|
+
|
|
40
|
+
# ── 3. remove orphaned definitions ────────────────────────────────
|
|
41
|
+
defs = schema.get("$defs", {})
|
|
42
|
+
for def_name in list(defs):
|
|
43
|
+
if def_name not in used_defs:
|
|
44
|
+
defs.pop(def_name)
|
|
45
|
+
if not defs:
|
|
46
|
+
schema.pop("$defs", None)
|
|
47
|
+
|
|
48
|
+
return schema
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prune_params(schema: dict, params: list[str]) -> dict:
|
|
52
|
+
"""
|
|
53
|
+
Remove the given parameters from the schema.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
schema = copy.deepcopy(schema)
|
|
57
|
+
for param in params:
|
|
58
|
+
schema = _prune_param(schema, param=param)
|
|
59
|
+
return schema
|