fastmcp 2.2.5__py3-none-any.whl → 2.2.7__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/server/server.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import datetime
4
6
  from collections.abc import AsyncIterator, Awaitable, Callable
5
7
  from contextlib import (
@@ -12,8 +14,13 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
12
14
 
13
15
  import anyio
14
16
  import httpx
15
- import pydantic_core
16
17
  import uvicorn
18
+ from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
19
+ from mcp.server.auth.middleware.bearer_auth import (
20
+ BearerAuthBackend,
21
+ RequireAuthMiddleware,
22
+ )
23
+ from mcp.server.auth.provider import OAuthAuthorizationServerProvider
17
24
  from mcp.server.lowlevel.helper_types import ReadResourceContents
18
25
  from mcp.server.lowlevel.server import LifespanResultT
19
26
  from mcp.server.lowlevel.server import Server as MCPServer
@@ -25,7 +32,9 @@ from mcp.types import (
25
32
  EmbeddedResource,
26
33
  GetPromptResult,
27
34
  ImageContent,
35
+ PromptMessage,
28
36
  TextContent,
37
+ ToolAnnotations,
29
38
  )
30
39
  from mcp.types import Prompt as MCPPrompt
31
40
  from mcp.types import Resource as MCPResource
@@ -33,8 +42,12 @@ from mcp.types import ResourceTemplate as MCPResourceTemplate
33
42
  from mcp.types import Tool as MCPTool
34
43
  from pydantic.networks import AnyUrl
35
44
  from starlette.applications import Starlette
45
+ from starlette.middleware import Middleware
46
+ from starlette.middleware.authentication import AuthenticationMiddleware
36
47
  from starlette.requests import Request
48
+ from starlette.responses import Response
37
49
  from starlette.routing import Mount, Route
50
+ from starlette.types import Receive, Scope, Send
38
51
 
39
52
  import fastmcp
40
53
  import fastmcp.settings
@@ -46,6 +59,7 @@ from fastmcp.resources.template import ResourceTemplate
46
59
  from fastmcp.tools import ToolManager
47
60
  from fastmcp.tools.tool import Tool
48
61
  from fastmcp.utilities.decorators import DecoratedFunction
62
+ from fastmcp.utilities.http import RequestMiddleware
49
63
  from fastmcp.utilities.logging import configure_logging, get_logger
50
64
 
51
65
  if TYPE_CHECKING:
@@ -63,7 +77,7 @@ class MountedServer:
63
77
  def __init__(
64
78
  self,
65
79
  prefix: str,
66
- server: "FastMCP",
80
+ server: FastMCP,
67
81
  tool_separator: str | None = None,
68
82
  resource_separator: str | None = None,
69
83
  prompt_separator: str | None = None,
@@ -149,7 +163,7 @@ class TimedCache:
149
163
 
150
164
 
151
165
  @asynccontextmanager
152
- async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
166
+ async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]:
153
167
  """Default lifespan context manager that does nothing.
154
168
 
155
169
  Args:
@@ -162,8 +176,8 @@ async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
162
176
 
163
177
 
164
178
  def _lifespan_wrapper(
165
- app: "FastMCP",
166
- lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
179
+ app: FastMCP,
180
+ lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]],
167
181
  ) -> Callable[
168
182
  [MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
169
183
  ]:
@@ -181,10 +195,17 @@ class FastMCP(Generic[LifespanResultT]):
181
195
  self,
182
196
  name: str | None = None,
183
197
  instructions: str | None = None,
198
+ auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
199
+ | None = None,
184
200
  lifespan: (
185
- Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None
201
+ Callable[
202
+ [FastMCP[LifespanResultT]],
203
+ AbstractAsyncContextManager[LifespanResultT],
204
+ ]
205
+ | None
186
206
  ) = None,
187
207
  tags: set[str] | None = None,
208
+ tool_serializer: Callable[[Any], str] | None = None,
188
209
  **settings: Any,
189
210
  ):
190
211
  self.tags: set[str] = tags or set()
@@ -198,7 +219,10 @@ class FastMCP(Generic[LifespanResultT]):
198
219
  self._mounted_servers: dict[str, MountedServer] = {}
199
220
 
200
221
  if lifespan is None:
222
+ self._has_lifespan = False
201
223
  lifespan = default_lifespan
224
+ else:
225
+ self._has_lifespan = True
202
226
 
203
227
  self._mcp_server = MCPServer[LifespanResultT](
204
228
  name=name or "FastMCP",
@@ -206,7 +230,8 @@ class FastMCP(Generic[LifespanResultT]):
206
230
  lifespan=_lifespan_wrapper(self, lifespan),
207
231
  )
208
232
  self._tool_manager = ToolManager(
209
- duplicate_behavior=self.settings.on_duplicate_tools
233
+ duplicate_behavior=self.settings.on_duplicate_tools,
234
+ serializer=tool_serializer,
210
235
  )
211
236
  self._resource_manager = ResourceManager(
212
237
  duplicate_behavior=self.settings.on_duplicate_resources
@@ -214,6 +239,15 @@ class FastMCP(Generic[LifespanResultT]):
214
239
  self._prompt_manager = PromptManager(
215
240
  duplicate_behavior=self.settings.on_duplicate_prompts
216
241
  )
242
+
243
+ if (self.settings.auth is not None) != (auth_server_provider is not None):
244
+ # TODO: after we support separate authorization servers (see
245
+ raise ValueError(
246
+ "settings.auth must be specified if and only if auth_server_provider "
247
+ "is specified"
248
+ )
249
+ self._auth_server_provider = auth_server_provider
250
+ self._custom_starlette_routes: list[Route] = []
217
251
  self.dependencies = self.settings.dependencies
218
252
 
219
253
  # Set up MCP protocol handlers
@@ -273,7 +307,7 @@ class FastMCP(Generic[LifespanResultT]):
273
307
  self._mcp_server.get_prompt()(self._mcp_get_prompt)
274
308
  self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
275
309
 
276
- def get_context(self) -> "Context[ServerSession, LifespanResultT]":
310
+ def get_context(self) -> Context[ServerSession, LifespanResultT]:
277
311
  """
278
312
  Returns a Context object. Note that the context will only be valid
279
313
  during a request; outside a request, most methods will error.
@@ -333,6 +367,50 @@ class FastMCP(Generic[LifespanResultT]):
333
367
  self._cache.set("prompts", prompts)
334
368
  return prompts
335
369
 
370
+ def custom_route(
371
+ self,
372
+ path: str,
373
+ methods: list[str],
374
+ name: str | None = None,
375
+ include_in_schema: bool = True,
376
+ ):
377
+ """
378
+ Decorator to register a custom HTTP route on the FastMCP server.
379
+
380
+ Allows adding arbitrary HTTP endpoints outside the standard MCP protocol,
381
+ which can be useful for OAuth callbacks, health checks, or admin APIs.
382
+ The handler function must be an async function that accepts a Starlette
383
+ Request and returns a Response.
384
+
385
+ Args:
386
+ path: URL path for the route (e.g., "/oauth/callback")
387
+ methods: List of HTTP methods to support (e.g., ["GET", "POST"])
388
+ name: Optional name for the route (to reference this route with
389
+ Starlette's reverse URL lookup feature)
390
+ include_in_schema: Whether to include in OpenAPI schema, defaults to True
391
+
392
+ Example:
393
+ @server.custom_route("/health", methods=["GET"])
394
+ async def health_check(request: Request) -> Response:
395
+ return JSONResponse({"status": "ok"})
396
+ """
397
+
398
+ def decorator(
399
+ func: Callable[[Request], Awaitable[Response]],
400
+ ) -> Callable[[Request], Awaitable[Response]]:
401
+ self._custom_starlette_routes.append(
402
+ Route(
403
+ path,
404
+ endpoint=func,
405
+ methods=methods,
406
+ name=name,
407
+ include_in_schema=include_in_schema,
408
+ )
409
+ )
410
+ return func
411
+
412
+ return decorator
413
+
336
414
  async def _mcp_list_tools(self) -> list[MCPTool]:
337
415
  """
338
416
  List all available tools, in the format expected by the low-level MCP
@@ -429,7 +507,12 @@ class FastMCP(Generic[LifespanResultT]):
429
507
  messages = await self._prompt_manager.render_prompt(
430
508
  name, arguments=arguments or {}, context=context
431
509
  )
432
- return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
510
+
511
+ return GetPromptResult(
512
+ messages=[
513
+ PromptMessage(role=m.role, content=m.content) for m in messages
514
+ ]
515
+ )
433
516
  else:
434
517
  for server in self._mounted_servers.values():
435
518
  if server.match_prompt(name):
@@ -444,6 +527,7 @@ class FastMCP(Generic[LifespanResultT]):
444
527
  name: str | None = None,
445
528
  description: str | None = None,
446
529
  tags: set[str] | None = None,
530
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
447
531
  ) -> None:
448
532
  """Add a tool to the server.
449
533
 
@@ -455,9 +539,17 @@ class FastMCP(Generic[LifespanResultT]):
455
539
  name: Optional name for the tool (defaults to function name)
456
540
  description: Optional description of what the tool does
457
541
  tags: Optional set of tags for categorizing the tool
542
+ annotations: Optional annotations about the tool's behavior
458
543
  """
544
+ if isinstance(annotations, dict):
545
+ annotations = ToolAnnotations(**annotations)
546
+
459
547
  self._tool_manager.add_tool_from_fn(
460
- fn, name=name, description=description, tags=tags
548
+ fn,
549
+ name=name,
550
+ description=description,
551
+ tags=tags,
552
+ annotations=annotations,
461
553
  )
462
554
  self._cache.clear()
463
555
 
@@ -466,6 +558,7 @@ class FastMCP(Generic[LifespanResultT]):
466
558
  name: str | None = None,
467
559
  description: str | None = None,
468
560
  tags: set[str] | None = None,
561
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
469
562
  ) -> Callable[[AnyFunction], AnyFunction]:
470
563
  """Decorator to register a tool.
471
564
 
@@ -477,6 +570,7 @@ class FastMCP(Generic[LifespanResultT]):
477
570
  name: Optional name for the tool (defaults to function name)
478
571
  description: Optional description of what the tool does
479
572
  tags: Optional set of tags for categorizing the tool
573
+ annotations: Optional annotations about the tool's behavior
480
574
 
481
575
  Example:
482
576
  @server.tool()
@@ -502,7 +596,13 @@ class FastMCP(Generic[LifespanResultT]):
502
596
  )
503
597
 
504
598
  def decorator(fn: AnyFunction) -> AnyFunction:
505
- self.add_tool(fn, name=name, description=description, tags=tags)
599
+ self.add_tool(
600
+ fn,
601
+ name=name,
602
+ description=description,
603
+ tags=tags,
604
+ annotations=annotations,
605
+ )
506
606
  return fn
507
607
 
508
608
  return decorator
@@ -728,10 +828,11 @@ class FastMCP(Generic[LifespanResultT]):
728
828
  log_level: str | None = None,
729
829
  ) -> None:
730
830
  """Run the server using SSE transport."""
731
- starlette_app = self.sse_app()
831
+ app = self.sse_app()
832
+ app = RequestMiddleware(app)
732
833
 
733
834
  config = uvicorn.Config(
734
- starlette_app,
835
+ app,
735
836
  host=host or self.settings.host,
736
837
  port=port or self.settings.port,
737
838
  log_level=log_level or self.settings.log_level.lower(),
@@ -741,39 +842,169 @@ class FastMCP(Generic[LifespanResultT]):
741
842
 
742
843
  def sse_app(self) -> Starlette:
743
844
  """Return an instance of the SSE server app."""
845
+ from starlette.middleware import Middleware
846
+ from starlette.routing import Mount, Route
847
+
848
+ # Set up auth context and dependencies
849
+
744
850
  sse = SseServerTransport(self.settings.message_path)
745
851
 
746
- async def handle_sse(request: Request) -> None:
852
+ async def handle_sse(scope: Scope, receive: Receive, send: Send):
853
+ # Add client ID from auth context into request context if available
854
+
747
855
  async with sse.connect_sse(
748
- request.scope,
749
- request.receive,
750
- request._send, # type: ignore[reportPrivateUsage]
856
+ scope,
857
+ receive,
858
+ send,
751
859
  ) as streams:
752
860
  await self._mcp_server.run(
753
861
  streams[0],
754
862
  streams[1],
755
863
  self._mcp_server.create_initialization_options(),
756
864
  )
865
+ return Response()
866
+
867
+ # Create routes
868
+ routes: list[Route | Mount] = []
869
+ middleware: list[Middleware] = []
870
+ required_scopes = []
871
+
872
+ # Add auth endpoints if auth provider is configured
873
+ if self._auth_server_provider:
874
+ assert self.settings.auth
875
+ from mcp.server.auth.routes import create_auth_routes
876
+
877
+ required_scopes = self.settings.auth.required_scopes or []
878
+
879
+ middleware = [
880
+ # extract auth info from request (but do not require it)
881
+ Middleware(
882
+ AuthenticationMiddleware,
883
+ backend=BearerAuthBackend(
884
+ provider=self._auth_server_provider,
885
+ ),
886
+ ),
887
+ # Add the auth context middleware to store
888
+ # authenticated user in a contextvar
889
+ Middleware(AuthContextMiddleware),
890
+ ]
891
+ routes.extend(
892
+ create_auth_routes(
893
+ provider=self._auth_server_provider,
894
+ issuer_url=self.settings.auth.issuer_url,
895
+ service_documentation_url=self.settings.auth.service_documentation_url,
896
+ client_registration_options=self.settings.auth.client_registration_options,
897
+ revocation_options=self.settings.auth.revocation_options,
898
+ )
899
+ )
757
900
 
901
+ # When auth is not configured, we shouldn't require auth
902
+ if self._auth_server_provider:
903
+ # Auth is enabled, wrap the endpoints with RequireAuthMiddleware
904
+ routes.append(
905
+ Route(
906
+ self.settings.sse_path,
907
+ endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
908
+ methods=["GET"],
909
+ )
910
+ )
911
+ routes.append(
912
+ Mount(
913
+ self.settings.message_path,
914
+ app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
915
+ )
916
+ )
917
+ else:
918
+ # Auth is disabled, no need for RequireAuthMiddleware
919
+ # Since handle_sse is an ASGI app, we need to create a compatible endpoint
920
+ async def sse_endpoint(request: Request) -> None:
921
+ # Convert the Starlette request to ASGI parameters
922
+ await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
923
+
924
+ routes.append(
925
+ Route(
926
+ self.settings.sse_path,
927
+ endpoint=sse_endpoint,
928
+ methods=["GET"],
929
+ )
930
+ )
931
+ routes.append(
932
+ Mount(
933
+ self.settings.message_path,
934
+ app=sse.handle_post_message,
935
+ )
936
+ )
937
+ # mount these routes last, so they have the lowest route matching precedence
938
+ routes.extend(self._custom_starlette_routes)
939
+
940
+ # Create Starlette app with routes and middleware
758
941
  return Starlette(
759
- debug=self.settings.debug,
760
- routes=[
761
- Route(self.settings.sse_path, endpoint=handle_sse),
762
- Mount(self.settings.message_path, app=sse.handle_post_message),
763
- ],
942
+ debug=self.settings.debug, routes=routes, middleware=middleware
764
943
  )
765
944
 
766
945
  def mount(
767
946
  self,
768
947
  prefix: str,
769
- server: "FastMCP",
948
+ server: FastMCP[LifespanResultT],
770
949
  tool_separator: str | None = None,
771
950
  resource_separator: str | None = None,
772
951
  prompt_separator: str | None = None,
952
+ as_proxy: bool | None = None,
773
953
  ) -> None:
954
+ """Mount another FastMCP server on this server with the given prefix.
955
+
956
+ Unlike importing (with import_server), mounting establishes a dynamic connection
957
+ between servers. When a client interacts with a mounted server's objects through
958
+ the parent server, requests are forwarded to the mounted server in real-time.
959
+ This means changes to the mounted server are immediately reflected when accessed
960
+ through the parent.
961
+
962
+ When a server is mounted:
963
+ - Tools from the mounted server are accessible with prefixed names using the tool_separator.
964
+ Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
965
+ - Resources are accessible with prefixed URIs using the resource_separator.
966
+ Example: If server has a resource with URI "weather://forecast", it will be available as
967
+ "prefix+weather://forecast".
968
+ - Templates are accessible with prefixed URI templates using the resource_separator.
969
+ Example: If server has a template with URI "weather://location/{id}", it will be available
970
+ as "prefix+weather://location/{id}".
971
+ - Prompts are accessible with prefixed names using the prompt_separator.
972
+ Example: If server has a prompt named "weather_prompt", it will be available as
973
+ "prefix_weather_prompt".
974
+
975
+ There are two modes for mounting servers:
976
+ 1. Direct mounting (default when server has no custom lifespan): The parent server
977
+ directly accesses the mounted server's objects in-memory for better performance.
978
+ In this mode, no client lifecycle events occur on the mounted server, including
979
+ lifespan execution.
980
+
981
+ 2. Proxy mounting (default when server has a custom lifespan): The parent server
982
+ treats the mounted server as a separate entity and communicates with it via a
983
+ Client transport. This preserves all client-facing behaviors, including lifespan
984
+ execution, but with slightly higher overhead.
985
+
986
+ Args:
987
+ prefix: Prefix to use for the mounted server's objects.
988
+ server: The FastMCP server to mount.
989
+ tool_separator: Separator character for tool names (defaults to "_").
990
+ resource_separator: Separator character for resource URIs (defaults to "+").
991
+ prompt_separator: Separator character for prompt names (defaults to "_").
992
+ as_proxy: Whether to treat the mounted server as a proxy. If None (default),
993
+ automatically determined based on whether the server has a custom lifespan
994
+ (True if it has a custom lifespan, False otherwise).
774
995
  """
775
- Mount another FastMCP server on a given prefix.
776
- """
996
+ from fastmcp import Client
997
+ from fastmcp.client.transports import FastMCPTransport
998
+ from fastmcp.server.proxy import FastMCPProxy
999
+
1000
+ # if as_proxy is not specified and the server has a custom lifespan,
1001
+ # we should treat it as a proxy
1002
+ if as_proxy is None:
1003
+ as_proxy = server._has_lifespan
1004
+
1005
+ if as_proxy and not isinstance(server, FastMCPProxy):
1006
+ server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
1007
+
777
1008
  mounted_server = MountedServer(
778
1009
  server=server,
779
1010
  prefix=prefix,
@@ -791,7 +1022,7 @@ class FastMCP(Generic[LifespanResultT]):
791
1022
  async def import_server(
792
1023
  self,
793
1024
  prefix: str,
794
- server: "FastMCP",
1025
+ server: FastMCP[LifespanResultT],
795
1026
  tool_separator: str | None = None,
796
1027
  resource_separator: str | None = None,
797
1028
  prompt_separator: str | None = None,
@@ -865,7 +1096,7 @@ class FastMCP(Generic[LifespanResultT]):
865
1096
  @classmethod
866
1097
  def from_openapi(
867
1098
  cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
868
- ) -> "FastMCPOpenAPI":
1099
+ ) -> FastMCPOpenAPI:
869
1100
  """
870
1101
  Create a FastMCP server from an OpenAPI specification.
871
1102
  """
@@ -875,8 +1106,8 @@ class FastMCP(Generic[LifespanResultT]):
875
1106
 
876
1107
  @classmethod
877
1108
  def from_fastapi(
878
- cls, app: "Any", name: str | None = None, **settings: Any
879
- ) -> "FastMCPOpenAPI":
1109
+ cls, app: Any, name: str | None = None, **settings: Any
1110
+ ) -> FastMCPOpenAPI:
880
1111
  """
881
1112
  Create a FastMCP server from a FastAPI application.
882
1113
  """
@@ -894,7 +1125,7 @@ class FastMCP(Generic[LifespanResultT]):
894
1125
  )
895
1126
 
896
1127
  @classmethod
897
- def from_client(cls, client: "Client", **settings: Any) -> "FastMCPProxy":
1128
+ def from_client(cls, client: Client, **settings: Any) -> FastMCPProxy:
898
1129
  """
899
1130
  Create a FastMCP proxy server from a FastMCP client.
900
1131
  """
fastmcp/settings.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations as _annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Literal
4
4
 
5
+ from mcp.server.auth.settings import AuthSettings
5
6
  from pydantic import Field
6
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
7
8
 
@@ -20,6 +21,8 @@ class Settings(BaseSettings):
20
21
  env_prefix="FASTMCP_",
21
22
  env_file=".env",
22
23
  extra="ignore",
24
+ env_nested_delimiter="__",
25
+ nested_model_default_partial_update=True,
23
26
  )
24
27
 
25
28
  test_mode: bool = False
@@ -37,6 +40,8 @@ class ServerSettings(BaseSettings):
37
40
  env_prefix="FASTMCP_SERVER_",
38
41
  env_file=".env",
39
42
  extra="ignore",
43
+ env_nested_delimiter="__",
44
+ nested_model_default_partial_update=True,
40
45
  )
41
46
 
42
47
  log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
@@ -65,6 +70,8 @@ class ServerSettings(BaseSettings):
65
70
  # cache settings (for checking mounted servers)
66
71
  cache_expiration_seconds: float = 0
67
72
 
73
+ auth: AuthSettings | None = None
74
+
68
75
 
69
76
  class ClientSettings(BaseSettings):
70
77
  """FastMCP client settings."""
fastmcp/tools/tool.py CHANGED
@@ -1,18 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- import json
5
4
  from collections.abc import Callable
6
5
  from typing import TYPE_CHECKING, Annotated, Any
7
6
 
8
7
  import pydantic_core
9
- from mcp.types import EmbeddedResource, ImageContent, TextContent
8
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
10
9
  from mcp.types import Tool as MCPTool
11
10
  from pydantic import BaseModel, BeforeValidator, Field
12
11
 
13
12
  from fastmcp.exceptions import ToolError
14
13
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
15
- from fastmcp.utilities.types import Image, _convert_set_defaults
14
+ from fastmcp.utilities.types import (
15
+ Image,
16
+ _convert_set_defaults,
17
+ is_class_member_of_type,
18
+ )
16
19
 
17
20
  if TYPE_CHECKING:
18
21
  from mcp.server.session import ServerSessionT
@@ -39,6 +42,12 @@ class Tool(BaseModel):
39
42
  tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
40
43
  default_factory=set, description="Tags for the tool"
41
44
  )
45
+ annotations: ToolAnnotations | None = Field(
46
+ None, description="Additional annotations about the tool"
47
+ )
48
+ serializer: Callable[[Any], str] | None = Field(
49
+ None, description="Optional custom serializer for tool results"
50
+ )
42
51
 
43
52
  @classmethod
44
53
  def from_function(
@@ -48,6 +57,8 @@ class Tool(BaseModel):
48
57
  description: str | None = None,
49
58
  context_kwarg: str | None = None,
50
59
  tags: set[str] | None = None,
60
+ annotations: ToolAnnotations | None = None,
61
+ serializer: Callable[[Any], str] | None = None,
51
62
  ) -> Tool:
52
63
  """Create a Tool from a function."""
53
64
  from fastmcp import Context
@@ -66,7 +77,7 @@ class Tool(BaseModel):
66
77
  else:
67
78
  sig = inspect.signature(fn)
68
79
  for param_name, param in sig.parameters.items():
69
- if param.annotation is Context:
80
+ if is_class_member_of_type(param.annotation, Context):
70
81
  context_kwarg = param_name
71
82
  break
72
83
 
@@ -92,6 +103,8 @@ class Tool(BaseModel):
92
103
  is_async=is_async,
93
104
  context_kwarg=context_kwarg,
94
105
  tags=tags or set(),
106
+ annotations=annotations,
107
+ serializer=serializer,
95
108
  )
96
109
 
97
110
  async def run(
@@ -112,7 +125,7 @@ class Tool(BaseModel):
112
125
  arguments_to_validate=arguments,
113
126
  arguments_to_pass_directly=pass_args,
114
127
  )
115
- return _convert_to_content(result)
128
+ return _convert_to_content(result, serializer=self.serializer)
116
129
  except Exception as e:
117
130
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
118
131
 
@@ -121,6 +134,7 @@ class Tool(BaseModel):
121
134
  "name": self.name,
122
135
  "description": self.description,
123
136
  "inputSchema": self.parameters,
137
+ "annotations": self.annotations,
124
138
  }
125
139
  return MCPTool(**kwargs | overrides)
126
140
 
@@ -132,6 +146,7 @@ class Tool(BaseModel):
132
146
 
133
147
  def _convert_to_content(
134
148
  result: Any,
149
+ serializer: Callable[[Any], str] | None = None,
135
150
  _process_as_single_item: bool = False,
136
151
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
137
152
  """Convert a result to a sequence of content objects."""
@@ -166,23 +181,10 @@ def _convert_to_content(
166
181
 
167
182
  return other_content + mcp_types
168
183
 
169
- # if the result is a bytes object, convert it to a text content object
170
184
  if not isinstance(result, str):
171
- try:
172
- jsonable_result = pydantic_core.to_jsonable_python(result)
173
- if jsonable_result is None:
174
- return [TextContent(type="text", text="null")]
175
- elif isinstance(jsonable_result, bool):
176
- return [
177
- TextContent(
178
- type="text", text="true" if jsonable_result else "false"
179
- )
180
- ]
181
- elif isinstance(jsonable_result, str | int | float):
182
- return [TextContent(type="text", text=str(jsonable_result))]
183
- else:
184
- return [TextContent(type="text", text=json.dumps(jsonable_result))]
185
- except Exception:
186
- result = str(result)
185
+ if serializer is not None:
186
+ result = serializer(result)
187
+ else:
188
+ result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
187
189
 
188
190
  return [TextContent(type="text", text=result)]
@@ -4,7 +4,7 @@ from collections.abc import Callable
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from mcp.shared.context import LifespanContextT
7
- from mcp.types import EmbeddedResource, ImageContent, TextContent
7
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
8
8
 
9
9
  from fastmcp.exceptions import NotFoundError
10
10
  from fastmcp.settings import DuplicateBehavior
@@ -22,8 +22,13 @@ logger = get_logger(__name__)
22
22
  class ToolManager:
23
23
  """Manages FastMCP tools."""
24
24
 
25
- def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
25
+ def __init__(
26
+ self,
27
+ duplicate_behavior: DuplicateBehavior | None = None,
28
+ serializer: Callable[[Any], str] | None = None,
29
+ ):
26
30
  self._tools: dict[str, Tool] = {}
31
+ self._serializer = serializer
27
32
 
28
33
  # Default to "warn" if None is provided
29
34
  if duplicate_behavior is None:
@@ -61,9 +66,17 @@ class ToolManager:
61
66
  name: str | None = None,
62
67
  description: str | None = None,
63
68
  tags: set[str] | None = None,
69
+ annotations: ToolAnnotations | None = None,
64
70
  ) -> Tool:
65
71
  """Add a tool to the server."""
66
- tool = Tool.from_function(fn, name=name, description=description, tags=tags)
72
+ tool = Tool.from_function(
73
+ fn,
74
+ name=name,
75
+ description=description,
76
+ tags=tags,
77
+ annotations=annotations,
78
+ serializer=self._serializer,
79
+ )
67
80
  return self.add_tool(tool)
68
81
 
69
82
  def add_tool(self, tool: Tool, key: str | None = None) -> Tool: