fastmcp 2.12.2__py3-none-any.whl → 2.12.4__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/cli/claude.py +1 -10
- fastmcp/cli/cli.py +45 -25
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +1 -10
- fastmcp/cli/install/claude_desktop.py +1 -9
- fastmcp/cli/install/cursor.py +2 -18
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +1 -9
- fastmcp/cli/run.py +2 -86
- fastmcp/client/auth/oauth.py +50 -37
- fastmcp/client/client.py +18 -8
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/transports.py +1 -1
- fastmcp/contrib/component_manager/component_service.py +1 -1
- fastmcp/contrib/mcp_mixin/README.md +3 -3
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +41 -6
- fastmcp/experimental/utilities/openapi/director.py +8 -1
- fastmcp/experimental/utilities/openapi/schemas.py +31 -5
- fastmcp/prompts/prompt.py +10 -8
- fastmcp/resources/resource.py +14 -11
- fastmcp/resources/template.py +12 -10
- fastmcp/server/auth/auth.py +10 -4
- fastmcp/server/auth/oauth_proxy.py +93 -23
- fastmcp/server/auth/oidc_proxy.py +348 -0
- fastmcp/server/auth/providers/auth0.py +174 -0
- fastmcp/server/auth/providers/aws.py +237 -0
- fastmcp/server/auth/providers/azure.py +6 -2
- fastmcp/server/auth/providers/descope.py +172 -0
- fastmcp/server/auth/providers/github.py +6 -2
- fastmcp/server/auth/providers/google.py +6 -2
- fastmcp/server/auth/providers/workos.py +6 -2
- fastmcp/server/context.py +17 -16
- fastmcp/server/dependencies.py +18 -5
- fastmcp/server/http.py +1 -1
- fastmcp/server/middleware/logging.py +147 -116
- fastmcp/server/middleware/middleware.py +3 -2
- fastmcp/server/openapi.py +5 -1
- fastmcp/server/server.py +43 -36
- fastmcp/settings.py +42 -6
- fastmcp/tools/tool.py +105 -87
- fastmcp/tools/tool_transform.py +1 -1
- fastmcp/utilities/json_schema.py +18 -1
- fastmcp/utilities/logging.py +66 -4
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +4 -39
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -2
- fastmcp/utilities/mcp_server_config/v1/schema.json +2 -1
- fastmcp/utilities/storage.py +204 -0
- fastmcp/utilities/tests.py +8 -6
- fastmcp/utilities/types.py +9 -5
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/METADATA +121 -48
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/RECORD +54 -48
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/server.py
CHANGED
|
@@ -8,11 +8,7 @@ import re
|
|
|
8
8
|
import secrets
|
|
9
9
|
import warnings
|
|
10
10
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
11
|
-
from contextlib import
|
|
12
|
-
AbstractAsyncContextManager,
|
|
13
|
-
AsyncExitStack,
|
|
14
|
-
asynccontextmanager,
|
|
15
|
-
)
|
|
11
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
|
16
12
|
from dataclasses import dataclass
|
|
17
13
|
from functools import partial
|
|
18
14
|
from pathlib import Path
|
|
@@ -65,7 +61,7 @@ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
|
|
65
61
|
from fastmcp.tools.tool_transform import ToolTransformConfig
|
|
66
62
|
from fastmcp.utilities.cli import log_server_banner
|
|
67
63
|
from fastmcp.utilities.components import FastMCPComponent
|
|
68
|
-
from fastmcp.utilities.logging import get_logger
|
|
64
|
+
from fastmcp.utilities.logging import get_logger, temporary_log_level
|
|
69
65
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
70
66
|
|
|
71
67
|
if TYPE_CHECKING:
|
|
@@ -208,8 +204,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
208
204
|
# if auth is `NotSet`, try to create a provider from the environment
|
|
209
205
|
if auth is NotSet:
|
|
210
206
|
if fastmcp.settings.server_auth is not None:
|
|
211
|
-
#
|
|
212
|
-
auth = fastmcp.settings.
|
|
207
|
+
# server_auth_class returns the class itself
|
|
208
|
+
auth = fastmcp.settings.server_auth_class()
|
|
213
209
|
else:
|
|
214
210
|
auth = None
|
|
215
211
|
self.auth = cast(AuthProvider | None, auth)
|
|
@@ -329,6 +325,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
329
325
|
def instructions(self) -> str | None:
|
|
330
326
|
return self._mcp_server.instructions
|
|
331
327
|
|
|
328
|
+
@instructions.setter
|
|
329
|
+
def instructions(self, value: str | None) -> None:
|
|
330
|
+
self._mcp_server.instructions = value
|
|
331
|
+
|
|
332
332
|
@property
|
|
333
333
|
def version(self) -> str | None:
|
|
334
334
|
return self._mcp_server.version
|
|
@@ -812,6 +812,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
812
812
|
|
|
813
813
|
Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
|
|
814
814
|
"""
|
|
815
|
+
import fastmcp.server.context
|
|
816
|
+
|
|
815
817
|
logger.debug(
|
|
816
818
|
f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments
|
|
817
819
|
)
|
|
@@ -1208,8 +1210,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1208
1210
|
return f"Weather for {city}"
|
|
1209
1211
|
|
|
1210
1212
|
@server.resource("resource://{city}/weather")
|
|
1211
|
-
def get_weather_with_context(city: str, ctx: Context) -> str:
|
|
1212
|
-
ctx.info(f"Fetching weather for {city}")
|
|
1213
|
+
async def get_weather_with_context(city: str, ctx: Context) -> str:
|
|
1214
|
+
await ctx.info(f"Fetching weather for {city}")
|
|
1213
1215
|
return f"Weather for {city}"
|
|
1214
1216
|
|
|
1215
1217
|
@server.resource("resource://{city}/weather")
|
|
@@ -1384,8 +1386,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1384
1386
|
]
|
|
1385
1387
|
|
|
1386
1388
|
@server.prompt()
|
|
1387
|
-
def analyze_with_context(table_name: str, ctx: Context) -> list[Message]:
|
|
1388
|
-
ctx.info(f"Analyzing table {table_name}")
|
|
1389
|
+
async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]:
|
|
1390
|
+
await ctx.info(f"Analyzing table {table_name}")
|
|
1389
1391
|
schema = read_table_schema(table_name)
|
|
1390
1392
|
return [
|
|
1391
1393
|
{
|
|
@@ -1395,7 +1397,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1395
1397
|
]
|
|
1396
1398
|
|
|
1397
1399
|
@server.prompt("custom_name")
|
|
1398
|
-
def analyze_file(path: str) -> list[Message]:
|
|
1400
|
+
async def analyze_file(path: str) -> list[Message]:
|
|
1399
1401
|
content = await read_file(path)
|
|
1400
1402
|
return [
|
|
1401
1403
|
{
|
|
@@ -1479,9 +1481,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1479
1481
|
meta=meta,
|
|
1480
1482
|
)
|
|
1481
1483
|
|
|
1482
|
-
async def run_stdio_async(
|
|
1483
|
-
|
|
1484
|
+
async def run_stdio_async(
|
|
1485
|
+
self, show_banner: bool = True, log_level: str | None = None
|
|
1486
|
+
) -> None:
|
|
1487
|
+
"""Run the server using stdio transport.
|
|
1484
1488
|
|
|
1489
|
+
Args:
|
|
1490
|
+
show_banner: Whether to display the server banner
|
|
1491
|
+
log_level: Log level for the server
|
|
1492
|
+
"""
|
|
1485
1493
|
# Display server banner
|
|
1486
1494
|
if show_banner:
|
|
1487
1495
|
log_server_banner(
|
|
@@ -1489,15 +1497,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1489
1497
|
transport="stdio",
|
|
1490
1498
|
)
|
|
1491
1499
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1500
|
+
with temporary_log_level(log_level):
|
|
1501
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
1502
|
+
logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'")
|
|
1503
|
+
await self._mcp_server.run(
|
|
1504
|
+
read_stream,
|
|
1505
|
+
write_stream,
|
|
1506
|
+
self._mcp_server.create_initialization_options(
|
|
1507
|
+
NotificationOptions(tools_changed=True)
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1501
1510
|
|
|
1502
1511
|
async def run_http_async(
|
|
1503
1512
|
self,
|
|
@@ -1523,7 +1532,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1523
1532
|
middleware: A list of middleware to apply to the app
|
|
1524
1533
|
stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http)
|
|
1525
1534
|
"""
|
|
1526
|
-
|
|
1527
1535
|
host = host or self._deprecated_settings.host
|
|
1528
1536
|
port = port or self._deprecated_settings.port
|
|
1529
1537
|
default_log_level_to_use = (
|
|
@@ -1564,14 +1572,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1564
1572
|
if "log_config" not in config_kwargs and "log_level" not in config_kwargs:
|
|
1565
1573
|
config_kwargs["log_level"] = default_log_level_to_use
|
|
1566
1574
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1575
|
+
with temporary_log_level(log_level):
|
|
1576
|
+
config = uvicorn.Config(app, host=host, port=port, **config_kwargs)
|
|
1577
|
+
server = uvicorn.Server(config)
|
|
1578
|
+
path = app.state.path.lstrip("/") # type: ignore
|
|
1579
|
+
logger.info(
|
|
1580
|
+
f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
|
|
1581
|
+
)
|
|
1573
1582
|
|
|
1574
|
-
|
|
1583
|
+
await server.serve()
|
|
1575
1584
|
|
|
1576
1585
|
async def run_sse_async(
|
|
1577
1586
|
self,
|
|
@@ -2120,10 +2129,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
2120
2129
|
# - Connected clients: reuse existing session for all requests
|
|
2121
2130
|
# - Disconnected clients: create fresh sessions per request for isolation
|
|
2122
2131
|
if client.is_connected():
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
logger = get_logger(__name__)
|
|
2126
|
-
logger.info(
|
|
2132
|
+
_proxy_logger = get_logger(__name__)
|
|
2133
|
+
_proxy_logger.info(
|
|
2127
2134
|
"Proxy detected connected client - reusing existing session for all requests. "
|
|
2128
2135
|
"This may cause context mixing in concurrent scenarios."
|
|
2129
2136
|
)
|
fastmcp/settings.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations as _annotations
|
|
|
3
3
|
import inspect
|
|
4
4
|
import warnings
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Annotated, Any, Literal
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
7
7
|
|
|
8
8
|
from pydantic import Field, ImportString, field_validator
|
|
9
9
|
from pydantic.fields import FieldInfo
|
|
@@ -23,6 +23,9 @@ LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
|
23
23
|
|
|
24
24
|
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
|
|
25
25
|
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from fastmcp.server.auth.auth import AuthProvider
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
class ExtendedEnvSettingsSource(EnvSettingsSource):
|
|
28
31
|
"""
|
|
@@ -258,7 +261,7 @@ class Settings(BaseSettings):
|
|
|
258
261
|
|
|
259
262
|
# Auth settings
|
|
260
263
|
server_auth: Annotated[
|
|
261
|
-
|
|
264
|
+
str | None,
|
|
262
265
|
Field(
|
|
263
266
|
description=inspect.cleandoc(
|
|
264
267
|
"""
|
|
@@ -266,13 +269,13 @@ class Settings(BaseSettings):
|
|
|
266
269
|
the full module path to an AuthProvider class (e.g.,
|
|
267
270
|
'fastmcp.server.auth.providers.google.GoogleProvider').
|
|
268
271
|
|
|
269
|
-
The specified class will be imported and instantiated automatically
|
|
270
|
-
Any class that inherits from AuthProvider
|
|
271
|
-
custom implementations.
|
|
272
|
+
The specified class will be imported and instantiated automatically
|
|
273
|
+
during FastMCP server creation. Any class that inherits from AuthProvider
|
|
274
|
+
can be used, including custom implementations.
|
|
272
275
|
|
|
273
276
|
If None, no automatic configuration will take place.
|
|
274
277
|
|
|
275
|
-
This setting is *always*
|
|
278
|
+
This setting is *always* overridden by any auth provider passed to the
|
|
276
279
|
FastMCP constructor.
|
|
277
280
|
|
|
278
281
|
Note that most auth providers require additional configuration
|
|
@@ -343,6 +346,39 @@ class Settings(BaseSettings):
|
|
|
343
346
|
),
|
|
344
347
|
] = False
|
|
345
348
|
|
|
349
|
+
show_cli_banner: Annotated[
|
|
350
|
+
bool,
|
|
351
|
+
Field(
|
|
352
|
+
default=True,
|
|
353
|
+
description=inspect.cleandoc(
|
|
354
|
+
"""
|
|
355
|
+
If True, the server banner will be displayed when running the server via CLI.
|
|
356
|
+
This setting can be overridden by the --no-banner CLI flag.
|
|
357
|
+
Set to False via FASTMCP_SHOW_CLI_BANNER=false to suppress the banner.
|
|
358
|
+
"""
|
|
359
|
+
),
|
|
360
|
+
),
|
|
361
|
+
] = True
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def server_auth_class(self) -> AuthProvider | None:
|
|
365
|
+
from fastmcp.utilities.types import get_cached_typeadapter
|
|
366
|
+
|
|
367
|
+
if not self.server_auth:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# https://github.com/jlowin/fastmcp/issues/1749
|
|
371
|
+
# Pydantic imports the module in an ImportString during model validation, but we don't want the server
|
|
372
|
+
# auth module imported during settings creation as it imports dependencies we aren't ready for yet.
|
|
373
|
+
# To fix this while limiting breaking changes, we delay the import by only creating the ImportString
|
|
374
|
+
# when the class is actually needed
|
|
375
|
+
|
|
376
|
+
type_adapter = get_cached_typeadapter(ImportString)
|
|
377
|
+
|
|
378
|
+
auth_class = type_adapter.validate_python(self.server_auth)
|
|
379
|
+
|
|
380
|
+
return auth_class
|
|
381
|
+
|
|
346
382
|
|
|
347
383
|
def __getattr__(name: str):
|
|
348
384
|
"""
|
fastmcp/tools/tool.py
CHANGED
|
@@ -10,6 +10,7 @@ from typing import (
|
|
|
10
10
|
Any,
|
|
11
11
|
Generic,
|
|
12
12
|
Literal,
|
|
13
|
+
TypeAlias,
|
|
13
14
|
get_type_hints,
|
|
14
15
|
)
|
|
15
16
|
|
|
@@ -55,6 +56,9 @@ class _UnserializableType:
|
|
|
55
56
|
pass
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
ToolResultSerializerType: TypeAlias = Callable[[Any], str]
|
|
60
|
+
|
|
61
|
+
|
|
58
62
|
def default_serializer(data: Any) -> str:
|
|
59
63
|
return pydantic_core.to_json(data, fallback=str).decode()
|
|
60
64
|
|
|
@@ -70,12 +74,12 @@ class ToolResult:
|
|
|
70
74
|
elif content is None:
|
|
71
75
|
content = structured_content
|
|
72
76
|
|
|
73
|
-
self.content = _convert_to_content(content)
|
|
77
|
+
self.content: list[ContentBlock] = _convert_to_content(result=content)
|
|
74
78
|
|
|
75
79
|
if structured_content is not None:
|
|
76
80
|
try:
|
|
77
81
|
structured_content = pydantic_core.to_jsonable_python(
|
|
78
|
-
structured_content
|
|
82
|
+
value=structured_content
|
|
79
83
|
)
|
|
80
84
|
except pydantic_core.PydanticSerializationError as e:
|
|
81
85
|
logger.error(
|
|
@@ -112,7 +116,7 @@ class Tool(FastMCPComponent):
|
|
|
112
116
|
Field(description="Additional annotations about the tool"),
|
|
113
117
|
] = None
|
|
114
118
|
serializer: Annotated[
|
|
115
|
-
|
|
119
|
+
ToolResultSerializerType | None,
|
|
116
120
|
Field(description="Optional custom serializer for tool results"),
|
|
117
121
|
] = None
|
|
118
122
|
|
|
@@ -138,23 +142,25 @@ class Tool(FastMCPComponent):
|
|
|
138
142
|
include_fastmcp_meta: bool | None = None,
|
|
139
143
|
**overrides: Any,
|
|
140
144
|
) -> MCPTool:
|
|
145
|
+
"""Convert the FastMCP tool to an MCP tool."""
|
|
146
|
+
title = None
|
|
147
|
+
|
|
141
148
|
if self.title:
|
|
142
149
|
title = self.title
|
|
143
150
|
elif self.annotations and self.annotations.title:
|
|
144
151
|
title = self.annotations.title
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return MCPTool(**kwargs | overrides)
|
|
152
|
+
|
|
153
|
+
return MCPTool(
|
|
154
|
+
name=overrides.get("name", self.name),
|
|
155
|
+
title=overrides.get("title", title),
|
|
156
|
+
description=overrides.get("description", self.description),
|
|
157
|
+
inputSchema=overrides.get("inputSchema", self.parameters),
|
|
158
|
+
outputSchema=overrides.get("outputSchema", self.output_schema),
|
|
159
|
+
annotations=overrides.get("annotations", self.annotations),
|
|
160
|
+
_meta=overrides.get(
|
|
161
|
+
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
|
|
162
|
+
),
|
|
163
|
+
)
|
|
158
164
|
|
|
159
165
|
@staticmethod
|
|
160
166
|
def from_function(
|
|
@@ -166,7 +172,7 @@ class Tool(FastMCPComponent):
|
|
|
166
172
|
annotations: ToolAnnotations | None = None,
|
|
167
173
|
exclude_args: list[str] | None = None,
|
|
168
174
|
output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
|
|
169
|
-
serializer:
|
|
175
|
+
serializer: ToolResultSerializerType | None = None,
|
|
170
176
|
meta: dict[str, Any] | None = None,
|
|
171
177
|
enabled: bool | None = None,
|
|
172
178
|
) -> FunctionTool:
|
|
@@ -208,7 +214,7 @@ class Tool(FastMCPComponent):
|
|
|
208
214
|
tags: set[str] | None = None,
|
|
209
215
|
annotations: ToolAnnotations | None | NotSetT = NotSet,
|
|
210
216
|
output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
|
|
211
|
-
serializer:
|
|
217
|
+
serializer: ToolResultSerializerType | None = None,
|
|
212
218
|
meta: dict[str, Any] | None | NotSetT = NotSet,
|
|
213
219
|
transform_args: dict[str, ArgTransform] | None = None,
|
|
214
220
|
enabled: bool | None = None,
|
|
@@ -246,7 +252,7 @@ class FunctionTool(Tool):
|
|
|
246
252
|
annotations: ToolAnnotations | None = None,
|
|
247
253
|
exclude_args: list[str] | None = None,
|
|
248
254
|
output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
|
|
249
|
-
serializer:
|
|
255
|
+
serializer: ToolResultSerializerType | None = None,
|
|
250
256
|
meta: dict[str, Any] | None = None,
|
|
251
257
|
enabled: bool | None = None,
|
|
252
258
|
) -> FunctionTool:
|
|
@@ -315,27 +321,33 @@ class FunctionTool(Tool):
|
|
|
315
321
|
|
|
316
322
|
unstructured_result = _convert_to_content(result, serializer=self.serializer)
|
|
317
323
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
# it as structured content. If it is not a dict, ignore it.
|
|
328
|
-
if structured_output is None:
|
|
324
|
+
if self.output_schema is None:
|
|
325
|
+
# Do not produce a structured output for MCP Content Types
|
|
326
|
+
if isinstance(result, ContentBlock | Audio | Image | File) or (
|
|
327
|
+
isinstance(result, list | tuple)
|
|
328
|
+
and any(isinstance(item, ContentBlock) for item in result)
|
|
329
|
+
):
|
|
330
|
+
return ToolResult(content=unstructured_result)
|
|
331
|
+
|
|
332
|
+
# Otherwise, try to serialize the result as a dict
|
|
329
333
|
try:
|
|
330
|
-
|
|
331
|
-
if
|
|
332
|
-
|
|
333
|
-
|
|
334
|
+
structured_content = pydantic_core.to_jsonable_python(result)
|
|
335
|
+
if isinstance(structured_content, dict):
|
|
336
|
+
return ToolResult(
|
|
337
|
+
content=unstructured_result,
|
|
338
|
+
structured_content=structured_content,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
except pydantic_core.PydanticSerializationError:
|
|
334
342
|
pass
|
|
335
343
|
|
|
344
|
+
return ToolResult(content=unstructured_result)
|
|
345
|
+
|
|
346
|
+
wrap_result = self.output_schema.get("x-fastmcp-wrap-result")
|
|
347
|
+
|
|
336
348
|
return ToolResult(
|
|
337
349
|
content=unstructured_result,
|
|
338
|
-
structured_content=
|
|
350
|
+
structured_content={"result": result} if wrap_result else result,
|
|
339
351
|
)
|
|
340
352
|
|
|
341
353
|
|
|
@@ -401,7 +413,9 @@ class ParsedFunction:
|
|
|
401
413
|
|
|
402
414
|
input_type_adapter = get_cached_typeadapter(fn)
|
|
403
415
|
input_schema = input_type_adapter.json_schema()
|
|
404
|
-
input_schema = compress_schema(
|
|
416
|
+
input_schema = compress_schema(
|
|
417
|
+
input_schema, prune_params=prune_params, prune_titles=True
|
|
418
|
+
)
|
|
405
419
|
|
|
406
420
|
output_schema = None
|
|
407
421
|
# Get the return annotation from the signature
|
|
@@ -461,7 +475,7 @@ class ParsedFunction:
|
|
|
461
475
|
else:
|
|
462
476
|
output_schema = base_schema
|
|
463
477
|
|
|
464
|
-
output_schema = compress_schema(output_schema)
|
|
478
|
+
output_schema = compress_schema(output_schema, prune_titles=True)
|
|
465
479
|
|
|
466
480
|
except PydanticSchemaGenerationError as e:
|
|
467
481
|
if "_UnserializableType" not in str(e):
|
|
@@ -476,65 +490,69 @@ class ParsedFunction:
|
|
|
476
490
|
)
|
|
477
491
|
|
|
478
492
|
|
|
479
|
-
def
|
|
480
|
-
result: Any,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
493
|
+
def _serialize_with_fallback(
|
|
494
|
+
result: Any, serializer: ToolResultSerializerType | None = None
|
|
495
|
+
) -> str:
|
|
496
|
+
if serializer is not None:
|
|
497
|
+
try:
|
|
498
|
+
return serializer(result)
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.warning(
|
|
501
|
+
"Error serializing tool result: %s",
|
|
502
|
+
e,
|
|
503
|
+
exc_info=True,
|
|
504
|
+
)
|
|
485
505
|
|
|
486
|
-
|
|
487
|
-
return []
|
|
506
|
+
return default_serializer(result)
|
|
488
507
|
|
|
489
|
-
if isinstance(result, ContentBlock):
|
|
490
|
-
return [result]
|
|
491
508
|
|
|
492
|
-
|
|
493
|
-
|
|
509
|
+
def _convert_to_single_content_block(
|
|
510
|
+
item: Any,
|
|
511
|
+
serializer: ToolResultSerializerType | None = None,
|
|
512
|
+
) -> ContentBlock:
|
|
513
|
+
if isinstance(item, ContentBlock):
|
|
514
|
+
return item
|
|
494
515
|
|
|
495
|
-
|
|
496
|
-
return
|
|
516
|
+
if isinstance(item, Image):
|
|
517
|
+
return item.to_image_content()
|
|
497
518
|
|
|
498
|
-
|
|
499
|
-
return
|
|
519
|
+
if isinstance(item, Audio):
|
|
520
|
+
return item.to_audio_content()
|
|
500
521
|
|
|
501
|
-
if isinstance(
|
|
502
|
-
|
|
503
|
-
# or a "regular" list that the tool is returning, or a mix of both.
|
|
504
|
-
#
|
|
505
|
-
# so we extract all the MCP types / images and convert them as individual content elements,
|
|
506
|
-
# and aggregate the rest as a single content element
|
|
522
|
+
if isinstance(item, File):
|
|
523
|
+
return item.to_resource_content()
|
|
507
524
|
|
|
508
|
-
|
|
509
|
-
|
|
525
|
+
if isinstance(item, str):
|
|
526
|
+
return TextContent(type="text", text=item)
|
|
510
527
|
|
|
511
|
-
|
|
512
|
-
if isinstance(item, ContentBlock | Image | Audio | File):
|
|
513
|
-
mcp_types.append(_convert_to_content(item)[0])
|
|
514
|
-
else:
|
|
515
|
-
other_content.append(item)
|
|
528
|
+
return TextContent(type="text", text=_serialize_with_fallback(item, serializer))
|
|
516
529
|
|
|
517
|
-
if other_content:
|
|
518
|
-
other_content = _convert_to_content(
|
|
519
|
-
other_content,
|
|
520
|
-
serializer=serializer,
|
|
521
|
-
_process_as_single_item=True,
|
|
522
|
-
)
|
|
523
530
|
|
|
524
|
-
|
|
531
|
+
def _convert_to_content(
|
|
532
|
+
result: Any,
|
|
533
|
+
serializer: ToolResultSerializerType | None = None,
|
|
534
|
+
) -> list[ContentBlock]:
|
|
535
|
+
"""Convert a result to a sequence of content objects."""
|
|
525
536
|
|
|
526
|
-
if
|
|
527
|
-
|
|
528
|
-
result = default_serializer(result)
|
|
529
|
-
else:
|
|
530
|
-
try:
|
|
531
|
-
result = serializer(result)
|
|
532
|
-
except Exception as e:
|
|
533
|
-
logger.warning(
|
|
534
|
-
"Error serializing tool result: %s",
|
|
535
|
-
e,
|
|
536
|
-
exc_info=True,
|
|
537
|
-
)
|
|
538
|
-
result = default_serializer(result)
|
|
537
|
+
if result is None:
|
|
538
|
+
return []
|
|
539
539
|
|
|
540
|
-
|
|
540
|
+
if not isinstance(result, (list | tuple)):
|
|
541
|
+
return [_convert_to_single_content_block(result, serializer)]
|
|
542
|
+
|
|
543
|
+
# If all items are ContentBlocks, return them as is
|
|
544
|
+
if all(isinstance(item, ContentBlock) for item in result):
|
|
545
|
+
return result
|
|
546
|
+
|
|
547
|
+
# If any item is a ContentBlock, convert non-ContentBlock items to TextContent
|
|
548
|
+
# without aggregating them
|
|
549
|
+
if any(isinstance(item, ContentBlock) for item in result):
|
|
550
|
+
return [
|
|
551
|
+
_convert_to_single_content_block(item, serializer)
|
|
552
|
+
if not isinstance(item, ContentBlock)
|
|
553
|
+
else item
|
|
554
|
+
for item in result
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
# If none of the items are ContentBlocks, aggregate all items into a single TextContent
|
|
558
|
+
return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))]
|
fastmcp/tools/tool_transform.py
CHANGED
|
@@ -934,7 +934,7 @@ def apply_transformations_to_tools(
|
|
|
934
934
|
tools: dict[str, Tool],
|
|
935
935
|
transformations: dict[str, ToolTransformConfig],
|
|
936
936
|
) -> dict[str, Tool]:
|
|
937
|
-
"""Apply a list of transformations to a list of tools. Tools that do not have any
|
|
937
|
+
"""Apply a list of transformations to a list of tools. Tools that do not have any transformations
|
|
938
938
|
are left unchanged.
|
|
939
939
|
"""
|
|
940
940
|
|
fastmcp/utilities/json_schema.py
CHANGED
|
@@ -109,8 +109,25 @@ def _single_pass_optimize(
|
|
|
109
109
|
root_refs.add(referenced_def)
|
|
110
110
|
|
|
111
111
|
# Apply cleanups
|
|
112
|
+
# Only remove "title" if it's a schema metadata field
|
|
113
|
+
# Schema objects have keywords like "type", "properties", "$ref", etc.
|
|
114
|
+
# If we see these, then "title" is metadata, not a property name
|
|
112
115
|
if prune_titles and "title" in node:
|
|
113
|
-
node
|
|
116
|
+
# Check if this looks like a schema node
|
|
117
|
+
if any(
|
|
118
|
+
k in node
|
|
119
|
+
for k in [
|
|
120
|
+
"type",
|
|
121
|
+
"properties",
|
|
122
|
+
"$ref",
|
|
123
|
+
"items",
|
|
124
|
+
"allOf",
|
|
125
|
+
"oneOf",
|
|
126
|
+
"anyOf",
|
|
127
|
+
"required",
|
|
128
|
+
]
|
|
129
|
+
):
|
|
130
|
+
node.pop("title")
|
|
114
131
|
|
|
115
132
|
if (
|
|
116
133
|
prune_additional_properties
|