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 +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 +24 -11
- fastmcp/tools/tool.py +62 -45
- fastmcp/utilities/json_schema.py +59 -0
- fastmcp/utilities/types.py +42 -2
- fastmcp-2.2.9.dist-info/METADATA +407 -0
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.9.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.9.dist-info}/WHEEL +0 -0
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.9.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.2.7.dist-info → fastmcp-2.2.9.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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
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.
|
|
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,32 @@ 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
|
|
111
|
+
injected_args = (
|
|
112
|
+
{self.context_kwarg: context} if self.context_kwarg is not None else {}
|
|
121
113
|
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
186
|
-
result =
|
|
194
|
+
if serializer is None:
|
|
195
|
+
result = default_serializer(result)
|
|
187
196
|
else:
|
|
188
|
-
|
|
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)]
|