fastmcp 2.2.6__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
@@ -14,8 +14,13 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
14
14
 
15
15
  import anyio
16
16
  import httpx
17
- import pydantic_core
18
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
19
24
  from mcp.server.lowlevel.helper_types import ReadResourceContents
20
25
  from mcp.server.lowlevel.server import LifespanResultT
21
26
  from mcp.server.lowlevel.server import Server as MCPServer
@@ -27,7 +32,9 @@ from mcp.types import (
27
32
  EmbeddedResource,
28
33
  GetPromptResult,
29
34
  ImageContent,
35
+ PromptMessage,
30
36
  TextContent,
37
+ ToolAnnotations,
31
38
  )
32
39
  from mcp.types import Prompt as MCPPrompt
33
40
  from mcp.types import Resource as MCPResource
@@ -35,8 +42,12 @@ from mcp.types import ResourceTemplate as MCPResourceTemplate
35
42
  from mcp.types import Tool as MCPTool
36
43
  from pydantic.networks import AnyUrl
37
44
  from starlette.applications import Starlette
45
+ from starlette.middleware import Middleware
46
+ from starlette.middleware.authentication import AuthenticationMiddleware
38
47
  from starlette.requests import Request
48
+ from starlette.responses import Response
39
49
  from starlette.routing import Mount, Route
50
+ from starlette.types import Receive, Scope, Send
40
51
 
41
52
  import fastmcp
42
53
  import fastmcp.settings
@@ -48,6 +59,7 @@ from fastmcp.resources.template import ResourceTemplate
48
59
  from fastmcp.tools import ToolManager
49
60
  from fastmcp.tools.tool import Tool
50
61
  from fastmcp.utilities.decorators import DecoratedFunction
62
+ from fastmcp.utilities.http import RequestMiddleware
51
63
  from fastmcp.utilities.logging import configure_logging, get_logger
52
64
 
53
65
  if TYPE_CHECKING:
@@ -183,6 +195,8 @@ class FastMCP(Generic[LifespanResultT]):
183
195
  self,
184
196
  name: str | None = None,
185
197
  instructions: str | None = None,
198
+ auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
199
+ | None = None,
186
200
  lifespan: (
187
201
  Callable[
188
202
  [FastMCP[LifespanResultT]],
@@ -191,6 +205,7 @@ class FastMCP(Generic[LifespanResultT]):
191
205
  | None
192
206
  ) = None,
193
207
  tags: set[str] | None = None,
208
+ tool_serializer: Callable[[Any], str] | None = None,
194
209
  **settings: Any,
195
210
  ):
196
211
  self.tags: set[str] = tags or set()
@@ -204,7 +219,10 @@ class FastMCP(Generic[LifespanResultT]):
204
219
  self._mounted_servers: dict[str, MountedServer] = {}
205
220
 
206
221
  if lifespan is None:
222
+ self._has_lifespan = False
207
223
  lifespan = default_lifespan
224
+ else:
225
+ self._has_lifespan = True
208
226
 
209
227
  self._mcp_server = MCPServer[LifespanResultT](
210
228
  name=name or "FastMCP",
@@ -212,7 +230,8 @@ class FastMCP(Generic[LifespanResultT]):
212
230
  lifespan=_lifespan_wrapper(self, lifespan),
213
231
  )
214
232
  self._tool_manager = ToolManager(
215
- duplicate_behavior=self.settings.on_duplicate_tools
233
+ duplicate_behavior=self.settings.on_duplicate_tools,
234
+ serializer=tool_serializer,
216
235
  )
217
236
  self._resource_manager = ResourceManager(
218
237
  duplicate_behavior=self.settings.on_duplicate_resources
@@ -220,6 +239,15 @@ class FastMCP(Generic[LifespanResultT]):
220
239
  self._prompt_manager = PromptManager(
221
240
  duplicate_behavior=self.settings.on_duplicate_prompts
222
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] = []
223
251
  self.dependencies = self.settings.dependencies
224
252
 
225
253
  # Set up MCP protocol handlers
@@ -339,6 +367,50 @@ class FastMCP(Generic[LifespanResultT]):
339
367
  self._cache.set("prompts", prompts)
340
368
  return prompts
341
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
+
342
414
  async def _mcp_list_tools(self) -> list[MCPTool]:
343
415
  """
344
416
  List all available tools, in the format expected by the low-level MCP
@@ -435,7 +507,12 @@ class FastMCP(Generic[LifespanResultT]):
435
507
  messages = await self._prompt_manager.render_prompt(
436
508
  name, arguments=arguments or {}, context=context
437
509
  )
438
- 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
+ )
439
516
  else:
440
517
  for server in self._mounted_servers.values():
441
518
  if server.match_prompt(name):
@@ -450,6 +527,7 @@ class FastMCP(Generic[LifespanResultT]):
450
527
  name: str | None = None,
451
528
  description: str | None = None,
452
529
  tags: set[str] | None = None,
530
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
453
531
  ) -> None:
454
532
  """Add a tool to the server.
455
533
 
@@ -461,9 +539,17 @@ class FastMCP(Generic[LifespanResultT]):
461
539
  name: Optional name for the tool (defaults to function name)
462
540
  description: Optional description of what the tool does
463
541
  tags: Optional set of tags for categorizing the tool
542
+ annotations: Optional annotations about the tool's behavior
464
543
  """
544
+ if isinstance(annotations, dict):
545
+ annotations = ToolAnnotations(**annotations)
546
+
465
547
  self._tool_manager.add_tool_from_fn(
466
- fn, name=name, description=description, tags=tags
548
+ fn,
549
+ name=name,
550
+ description=description,
551
+ tags=tags,
552
+ annotations=annotations,
467
553
  )
468
554
  self._cache.clear()
469
555
 
@@ -472,6 +558,7 @@ class FastMCP(Generic[LifespanResultT]):
472
558
  name: str | None = None,
473
559
  description: str | None = None,
474
560
  tags: set[str] | None = None,
561
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
475
562
  ) -> Callable[[AnyFunction], AnyFunction]:
476
563
  """Decorator to register a tool.
477
564
 
@@ -483,6 +570,7 @@ class FastMCP(Generic[LifespanResultT]):
483
570
  name: Optional name for the tool (defaults to function name)
484
571
  description: Optional description of what the tool does
485
572
  tags: Optional set of tags for categorizing the tool
573
+ annotations: Optional annotations about the tool's behavior
486
574
 
487
575
  Example:
488
576
  @server.tool()
@@ -508,7 +596,13 @@ class FastMCP(Generic[LifespanResultT]):
508
596
  )
509
597
 
510
598
  def decorator(fn: AnyFunction) -> AnyFunction:
511
- 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
+ )
512
606
  return fn
513
607
 
514
608
  return decorator
@@ -734,10 +828,11 @@ class FastMCP(Generic[LifespanResultT]):
734
828
  log_level: str | None = None,
735
829
  ) -> None:
736
830
  """Run the server using SSE transport."""
737
- starlette_app = self.sse_app()
831
+ app = self.sse_app()
832
+ app = RequestMiddleware(app)
738
833
 
739
834
  config = uvicorn.Config(
740
- starlette_app,
835
+ app,
741
836
  host=host or self.settings.host,
742
837
  port=port or self.settings.port,
743
838
  log_level=log_level or self.settings.log_level.lower(),
@@ -747,26 +842,104 @@ class FastMCP(Generic[LifespanResultT]):
747
842
 
748
843
  def sse_app(self) -> Starlette:
749
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
+
750
850
  sse = SseServerTransport(self.settings.message_path)
751
851
 
752
- 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
+
753
855
  async with sse.connect_sse(
754
- request.scope,
755
- request.receive,
756
- request._send, # type: ignore[reportPrivateUsage]
856
+ scope,
857
+ receive,
858
+ send,
757
859
  ) as streams:
758
860
  await self._mcp_server.run(
759
861
  streams[0],
760
862
  streams[1],
761
863
  self._mcp_server.create_initialization_options(),
762
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
+ )
763
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
764
941
  return Starlette(
765
- debug=self.settings.debug,
766
- routes=[
767
- Route(self.settings.sse_path, endpoint=handle_sse),
768
- Mount(self.settings.message_path, app=sse.handle_post_message),
769
- ],
942
+ debug=self.settings.debug, routes=routes, middleware=middleware
770
943
  )
771
944
 
772
945
  def mount(
@@ -776,10 +949,62 @@ class FastMCP(Generic[LifespanResultT]):
776
949
  tool_separator: str | None = None,
777
950
  resource_separator: str | None = None,
778
951
  prompt_separator: str | None = None,
952
+ as_proxy: bool | None = None,
779
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).
780
995
  """
781
- Mount another FastMCP server on a given prefix.
782
- """
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
+
783
1008
  mounted_server = MountedServer(
784
1009
  server=server,
785
1010
  prefix=prefix,
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:
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import (
4
+ asynccontextmanager,
5
+ )
6
+ from contextvars import ContextVar
7
+
8
+ from starlette.requests import Request
9
+
10
+ from fastmcp.utilities.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ _current_starlette_request: ContextVar[Request | None] = ContextVar(
16
+ "starlette_request",
17
+ default=None,
18
+ )
19
+
20
+
21
+ @asynccontextmanager
22
+ async def starlette_request_context(request: Request):
23
+ token = _current_starlette_request.set(request)
24
+ try:
25
+ yield
26
+ finally:
27
+ _current_starlette_request.reset(token)
28
+
29
+
30
+ def get_current_starlette_request() -> Request | None:
31
+ return _current_starlette_request.get()
32
+
33
+
34
+ class RequestMiddleware:
35
+ """
36
+ Middleware that stores each request in a ContextVar
37
+ """
38
+
39
+ def __init__(self, app):
40
+ self.app = app
41
+
42
+ async def __call__(self, scope, receive, send):
43
+ async with starlette_request_context(Request(scope)):
44
+ await self.app(scope, receive, send)