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.
Files changed (54) hide show
  1. fastmcp/cli/claude.py +1 -10
  2. fastmcp/cli/cli.py +45 -25
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +1 -10
  5. fastmcp/cli/install/claude_desktop.py +1 -9
  6. fastmcp/cli/install/cursor.py +2 -18
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +1 -9
  9. fastmcp/cli/run.py +2 -86
  10. fastmcp/client/auth/oauth.py +50 -37
  11. fastmcp/client/client.py +18 -8
  12. fastmcp/client/elicitation.py +6 -1
  13. fastmcp/client/transports.py +1 -1
  14. fastmcp/contrib/component_manager/component_service.py +1 -1
  15. fastmcp/contrib/mcp_mixin/README.md +3 -3
  16. fastmcp/contrib/mcp_mixin/mcp_mixin.py +41 -6
  17. fastmcp/experimental/utilities/openapi/director.py +8 -1
  18. fastmcp/experimental/utilities/openapi/schemas.py +31 -5
  19. fastmcp/prompts/prompt.py +10 -8
  20. fastmcp/resources/resource.py +14 -11
  21. fastmcp/resources/template.py +12 -10
  22. fastmcp/server/auth/auth.py +10 -4
  23. fastmcp/server/auth/oauth_proxy.py +93 -23
  24. fastmcp/server/auth/oidc_proxy.py +348 -0
  25. fastmcp/server/auth/providers/auth0.py +174 -0
  26. fastmcp/server/auth/providers/aws.py +237 -0
  27. fastmcp/server/auth/providers/azure.py +6 -2
  28. fastmcp/server/auth/providers/descope.py +172 -0
  29. fastmcp/server/auth/providers/github.py +6 -2
  30. fastmcp/server/auth/providers/google.py +6 -2
  31. fastmcp/server/auth/providers/workos.py +6 -2
  32. fastmcp/server/context.py +17 -16
  33. fastmcp/server/dependencies.py +18 -5
  34. fastmcp/server/http.py +1 -1
  35. fastmcp/server/middleware/logging.py +147 -116
  36. fastmcp/server/middleware/middleware.py +3 -2
  37. fastmcp/server/openapi.py +5 -1
  38. fastmcp/server/server.py +43 -36
  39. fastmcp/settings.py +42 -6
  40. fastmcp/tools/tool.py +105 -87
  41. fastmcp/tools/tool_transform.py +1 -1
  42. fastmcp/utilities/json_schema.py +18 -1
  43. fastmcp/utilities/logging.py +66 -4
  44. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +4 -39
  45. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -2
  46. fastmcp/utilities/mcp_server_config/v1/schema.json +2 -1
  47. fastmcp/utilities/storage.py +204 -0
  48. fastmcp/utilities/tests.py +8 -6
  49. fastmcp/utilities/types.py +9 -5
  50. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/METADATA +121 -48
  51. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/RECORD +54 -48
  52. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/WHEEL +0 -0
  53. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/entry_points.txt +0 -0
  54. {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
- # ImportString returns the class itself
212
- auth = fastmcp.settings.server_auth()
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(self, show_banner: bool = True) -> None:
1483
- """Run the server using stdio transport."""
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
- async with stdio_server() as (read_stream, write_stream):
1493
- logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'")
1494
- await self._mcp_server.run(
1495
- read_stream,
1496
- write_stream,
1497
- self._mcp_server.create_initialization_options(
1498
- NotificationOptions(tools_changed=True)
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
- config = uvicorn.Config(app, host=host, port=port, **config_kwargs)
1568
- server = uvicorn.Server(config)
1569
- path = app.state.path.lstrip("/") # type: ignore
1570
- logger.info(
1571
- f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
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
- await server.serve()
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
- from fastmcp.utilities.logging import get_logger
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
- ImportString | None,
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 can be used, including
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* overriden by any auth provider passed to the
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
- Callable[[Any], str] | None,
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
- else:
146
- title = None
147
-
148
- kwargs = {
149
- "name": self.name,
150
- "description": self.description,
151
- "inputSchema": self.parameters,
152
- "outputSchema": self.output_schema,
153
- "annotations": self.annotations,
154
- "title": title,
155
- "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
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: Callable[[Any], str] | None = None,
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: Callable[[Any], str] | None = None,
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: Callable[[Any], str] | None = None,
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
- structured_output = None
319
- # First handle structured content based on output schema, if any
320
- if self.output_schema is not None:
321
- if self.output_schema.get("x-fastmcp-wrap-result"):
322
- # Schema says wrap - always wrap in result key
323
- structured_output = {"result": result}
324
- else:
325
- structured_output = result
326
- # If no output schema, try to serialize the result. If it is a dict, use
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
- structured_output = pydantic_core.to_jsonable_python(result)
331
- if not isinstance(structured_output, dict):
332
- structured_output = None
333
- except Exception:
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=structured_output,
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(input_schema, prune_params=prune_params)
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 _convert_to_content(
480
- result: Any,
481
- serializer: Callable[[Any], str] | None = None,
482
- _process_as_single_item: bool = False,
483
- ) -> list[ContentBlock]:
484
- """Convert a result to a sequence of content objects."""
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
- if result is None:
487
- return []
506
+ return default_serializer(result)
488
507
 
489
- if isinstance(result, ContentBlock):
490
- return [result]
491
508
 
492
- if isinstance(result, Image):
493
- return [result.to_image_content()]
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
- elif isinstance(result, Audio):
496
- return [result.to_audio_content()]
516
+ if isinstance(item, Image):
517
+ return item.to_image_content()
497
518
 
498
- elif isinstance(result, File):
499
- return [result.to_resource_content()]
519
+ if isinstance(item, Audio):
520
+ return item.to_audio_content()
500
521
 
501
- if isinstance(result, list | tuple) and not _process_as_single_item:
502
- # if the result is a list, then it could either be a list of MCP types,
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
- mcp_types = []
509
- other_content = []
525
+ if isinstance(item, str):
526
+ return TextContent(type="text", text=item)
510
527
 
511
- for item in result:
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
- return other_content + mcp_types
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 not isinstance(result, str):
527
- if serializer is None:
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
- return [TextContent(type="text", text=result)]
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))]
@@ -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 transforamtions
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
 
@@ -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.pop("title")
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