Skip to content

Commit e40055a

Browse files
committed
Support configuring async tool keepalives
1 parent 011a363 commit e40055a

File tree

6 files changed

+248
-1
lines changed

6 files changed

+248
-1
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[Lifespan
130130

131131

132132
class FastMCP(Generic[LifespanResultT]):
133+
_tool_manager: ToolManager
134+
133135
def __init__(
134136
self,
135137
name: str | None = None,
@@ -361,6 +363,7 @@ async def list_tools(self) -> list[MCPTool]:
361363
outputSchema=info.output_schema,
362364
annotations=info.annotations,
363365
invocationMode=self._get_invocation_mode(info, client_supports_async),
366+
_meta=info.meta,
364367
)
365368
for info in tools
366369
if client_supports_async or info.invocation_modes != ["async"]
@@ -434,6 +437,7 @@ def add_tool(
434437
annotations: ToolAnnotations | None = None,
435438
structured_output: bool | None = None,
436439
invocation_modes: list[InvocationMode] | None = None,
440+
keep_alive: int | None = None,
437441
) -> None:
438442
"""Add a tool to the server.
439443
@@ -452,6 +456,8 @@ def add_tool(
452456
- If False, unconditionally creates an unstructured tool
453457
invocation_modes: List of supported invocation modes (e.g., ["sync", "async"])
454458
- If None, defaults to ["sync"] for backwards compatibility
459+
keep_alive: How long (in seconds) async operation results should be kept available.
460+
Only applies to async tools.
455461
"""
456462
self._tool_manager.add_tool(
457463
fn,
@@ -461,6 +467,7 @@ def add_tool(
461467
annotations=annotations,
462468
structured_output=structured_output,
463469
invocation_modes=invocation_modes,
470+
keep_alive=keep_alive,
464471
)
465472

466473
def tool(
@@ -471,6 +478,7 @@ def tool(
471478
annotations: ToolAnnotations | None = None,
472479
structured_output: bool | None = None,
473480
invocation_modes: list[InvocationMode] | None = None,
481+
keep_alive: int | None = None,
474482
) -> Callable[[AnyFunction], AnyFunction]:
475483
"""Decorator to register a tool.
476484
@@ -491,6 +499,8 @@ def tool(
491499
- If None, defaults to ["sync"] for backwards compatibility
492500
- Supports "sync" for synchronous execution and "async" for asynchronous execution
493501
- Tools with "async" mode will be hidden from clients that don't support async execution
502+
keep_alive: How long (in seconds) async operation results should be kept available.
503+
Only applies to async tools.
494504
495505
Example:
496506
@server.tool()
@@ -533,6 +543,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
533543
annotations=annotations,
534544
structured_output=structured_output,
535545
invocation_modes=invocation_modes,
546+
keep_alive=keep_alive,
536547
)
537548
return fn
538549

src/mcp/server/fastmcp/tools/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Tool(BaseModel):
3838
invocation_modes: list[InvocationMode] = Field(
3939
default=["sync"], description="Supported invocation modes (sync/async)"
4040
)
41+
meta: dict[str, Any] | None = Field(description="Optional additional tool information.", default=None)
4142

4243
@cached_property
4344
def output_schema(self) -> dict[str, Any] | None:
@@ -54,6 +55,7 @@ def from_function(
5455
annotations: ToolAnnotations | None = None,
5556
structured_output: bool | None = None,
5657
invocation_modes: list[InvocationMode] | None = None,
58+
meta: dict[str, Any] | None = None,
5759
) -> Tool:
5860
"""Create a Tool from a function."""
5961
func_name = name or fn.__name__
@@ -89,6 +91,7 @@ def from_function(
8991
context_kwarg=context_kwarg,
9092
annotations=annotations,
9193
invocation_modes=invocation_modes,
94+
meta=meta,
9295
)
9396

9497
async def run(

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,38 @@ def add_tool(
5151
annotations: ToolAnnotations | None = None,
5252
structured_output: bool | None = None,
5353
invocation_modes: list[InvocationMode] | None = None,
54+
keep_alive: int | None = None,
55+
meta: dict[str, Any] | None = None,
5456
) -> Tool:
5557
"""Add a tool to the server."""
5658
# Default to sync mode if no invocation modes specified
5759
if invocation_modes is None:
5860
invocation_modes = ["sync"]
5961

62+
# Set appropriate default keep_alive based on async compatibility
63+
# if user didn't specify custom keep_alive
64+
if keep_alive is None and "async" in invocation_modes:
65+
keep_alive = 3600 # Default for async-compatible tools
66+
67+
# Validate keep_alive is only used with async-compatible tools
68+
if keep_alive is not None and "async" not in invocation_modes:
69+
raise ValueError(
70+
f"keep_alive parameter can only be used with async-compatible tools. "
71+
f"Tool '{name or fn.__name__}' has invocation_modes={invocation_modes} "
72+
f"but specifies keep_alive={keep_alive}. "
73+
f"Add 'async' to invocation_modes to use keep_alive."
74+
)
75+
76+
meta = meta or {}
77+
if keep_alive is not None:
78+
meta.update(
79+
{
80+
# default keepalive value is stashed in _meta to pass it to the lowlevel Server
81+
# without adding it to the actual protocol-level tool definition
82+
"_keep_alive": keep_alive
83+
}
84+
)
85+
6086
tool = Tool.from_function(
6187
fn,
6288
name=name,
@@ -65,6 +91,7 @@ def add_tool(
6591
annotations=annotations,
6692
structured_output=structured_output,
6793
invocation_modes=invocation_modes,
94+
meta=meta,
6895
)
6996
existing = self._tools.get(tool.name)
7097
if existing:

src/mcp/server/lowlevel/server.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,13 @@ async def handler(req: types.CallToolRequest):
468468

469469
# Check for async execution
470470
if tool and self.async_operations and self._should_execute_async(tool):
471+
keep_alive = self._get_tool_keep_alive(tool)
472+
471473
# Create async operation
472474
operation = self.async_operations.create_operation(
473475
tool_name=tool_name,
474476
arguments=arguments,
477+
keep_alive=keep_alive,
475478
)
476479
logger.debug(f"Created async operation with token: {operation.token}")
477480

@@ -499,7 +502,7 @@ async def execute_async():
499502
content=[],
500503
operation=types.AsyncResultProperties(
501504
token=operation.token,
502-
keepAlive=3600,
505+
keepAlive=operation.keep_alive,
503506
),
504507
)
505508
)
@@ -576,6 +579,12 @@ def _should_execute_async(self, tool: types.Tool) -> bool:
576579
invocation_mode = getattr(tool, "invocationMode", None)
577580
return invocation_mode == "async"
578581

582+
def _get_tool_keep_alive(self, tool: types.Tool) -> int:
583+
"""Get the keepalive value for an async tool."""
584+
if not tool.meta or "_keep_alive" not in tool.meta:
585+
raise ValueError(f"_keep_alive not defined for tool {tool.name}")
586+
return cast(int, tool.meta["_keep_alive"])
587+
579588
def progress_notification(self):
580589
def decorator(
581590
func: Callable[[str | int, float, float | None, str | None], Awaitable[None]],

tests/server/fastmcp/test_server.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,125 @@ async def async_invalid_tool() -> list[int]:
749749
pytest.fail("Operation should have failed due to validation error")
750750
await asyncio.sleep(0.01)
751751

752+
@pytest.mark.anyio
753+
async def test_tool_keep_alive_validation_no_sync_only(self):
754+
"""Test that keep_alive validation prevents use on sync-only tools."""
755+
mcp = FastMCP()
756+
757+
# Should raise error when keep_alive is used on sync-only tool
758+
with pytest.raises(ValueError, match="keep_alive parameter can only be used with async-compatible tools"):
759+
760+
@mcp.tool(keep_alive=1800) # Custom keep_alive on sync-only tool
761+
def sync_only_tool(x: int) -> str:
762+
return str(x)
763+
764+
@pytest.mark.anyio
765+
async def test_tool_keep_alive_default_async_tools(self):
766+
"""Test that async tools get correct default keep_alive."""
767+
mcp = FastMCP()
768+
769+
# Async tools should get default keep_alive of 3600
770+
@mcp.tool(invocation_modes=["async"]) # No keep_alive specified
771+
def async_tool_default(x: int) -> str:
772+
return str(x)
773+
774+
tools = mcp._tool_manager.list_tools()
775+
tool = next(t for t in tools if t.name == "async_tool_default")
776+
assert tool.meta is not None
777+
assert tool.meta["_keep_alive"] == 3600
778+
779+
@pytest.mark.anyio
780+
async def test_async_tool_keep_alive_expiry(self):
781+
"""Test that async operations expire after keep_alive duration."""
782+
mcp = FastMCP("AsyncKeepAliveTest")
783+
784+
@mcp.tool(invocation_modes=["async"], keep_alive=1) # 1 second keep_alive
785+
def short_lived_tool(data: str) -> str:
786+
return f"Processed: {data}"
787+
788+
# Check that the tool has correct keep_alive
789+
tools = mcp._tool_manager.list_tools()
790+
tool = next(t for t in tools if t.name == "short_lived_tool")
791+
assert tool.meta is not None
792+
assert tool.meta["_keep_alive"] == 1
793+
794+
async with client_session(mcp._mcp_server, protocol_version="next") as client:
795+
# First list tools to populate keep_alive mapping
796+
await client.list_tools()
797+
798+
# Call the async tool
799+
result = await client.call_tool("short_lived_tool", {"data": "test"})
800+
801+
# Should get operation token
802+
assert result.operation is not None
803+
token = result.operation.token
804+
assert result.operation.keepAlive == 1
805+
806+
# Wait for operation to complete
807+
while True:
808+
status = await client.get_operation_status(token)
809+
if status.status == "completed":
810+
break
811+
812+
# Get result while still alive
813+
operation_result = await client.get_operation_result(token)
814+
assert operation_result.result is not None
815+
816+
# Wait for keep_alive to expire (1 second + buffer)
817+
await asyncio.sleep(1.2)
818+
819+
# Operation should now be expired/unavailable
820+
with pytest.raises(Exception): # Should raise error for expired operation
821+
await client.get_operation_result(token)
822+
823+
@pytest.mark.anyio
824+
async def test_async_tool_keep_alive_expiry_structured_content(self):
825+
"""Test that async operations with structured content expire correctly."""
826+
mcp = FastMCP("AsyncKeepAliveStructuredTest")
827+
828+
class ProcessResult(BaseModel):
829+
status: str
830+
data: str
831+
count: int
832+
833+
@mcp.tool(invocation_modes=["async"], keep_alive=1) # 1 second keep_alive
834+
def structured_tool(input_data: str) -> ProcessResult:
835+
return ProcessResult(status="success", data=f"Processed: {input_data}", count=42)
836+
837+
async with client_session(mcp._mcp_server, protocol_version="next") as client:
838+
# First list tools to populate keep_alive mapping
839+
await client.list_tools()
840+
841+
# Call the async tool
842+
result = await client.call_tool("structured_tool", {"input_data": "test"})
843+
844+
# Should get operation token
845+
assert result.operation is not None
846+
token = result.operation.token
847+
assert result.operation.keepAlive == 1
848+
849+
# Wait for operation to complete
850+
while True:
851+
status = await client.get_operation_status(token)
852+
if status.status == "completed":
853+
break
854+
855+
# Get structured result while still alive
856+
operation_result = await client.get_operation_result(token)
857+
assert operation_result.result is not None
858+
assert operation_result.result.structuredContent is not None
859+
structured_data = operation_result.result.structuredContent
860+
assert structured_data["status"] == "success"
861+
assert structured_data["data"] == "Processed: test"
862+
assert structured_data["count"] == 42
863+
864+
# Wait for keep_alive to expire (1 second + buffer)
865+
await asyncio.sleep(1.2)
866+
867+
# Operation should now be expired/unavailable - validation should fail gracefully
868+
with pytest.raises(Exception): # Should raise error for expired operation
869+
await client.get_operation_result(token)
870+
752871

753872
class TestServerResources:
754873
@pytest.mark.anyio

tests/server/fastmcp/test_tool_manager.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,84 @@ def get_user() -> UserOutput:
633633
}
634634
assert tool.output_schema == expected_schema
635635

636+
def test_tool_meta_property(self):
637+
"""Test that Tool.meta property works correctly."""
638+
639+
def double_number(n: int) -> int:
640+
"""Double a number."""
641+
return 10
642+
643+
manager = ToolManager()
644+
tool = manager.add_tool(double_number, meta={"foo": "bar"})
645+
646+
# Test that meta is populated
647+
expected_meta = {
648+
"foo": "bar",
649+
}
650+
assert tool.meta == expected_meta
651+
652+
def test_tool_keep_alive_property_sync(self):
653+
"""Test that keep_alive property works correctly with sync-only tools."""
654+
655+
def double_number(n: int) -> int:
656+
"""Double a number."""
657+
return 10
658+
659+
manager = ToolManager()
660+
661+
# Should raise error when keep_alive is used on sync-only tool
662+
with pytest.raises(ValueError, match="keep_alive parameter can only be used with async-compatible tools"):
663+
manager.add_tool(double_number, invocation_modes=["sync"], keep_alive=1)
664+
665+
def test_tool_keep_alive_property_async(self):
666+
"""Test that keep_alive property works correctly with async-only tools."""
667+
668+
def double_number(n: int) -> int:
669+
"""Double a number."""
670+
return 10
671+
672+
manager = ToolManager()
673+
tool = manager.add_tool(double_number, invocation_modes=["async"], keep_alive=1)
674+
675+
# Test that meta is populated and has the keepalive stashed in it
676+
expected_meta = {
677+
"_keep_alive": 1,
678+
}
679+
assert tool.meta == expected_meta
680+
681+
def test_tool_keep_alive_property_hybrid(self):
682+
"""Test that keep_alive property works correctly with hybrid sync/async tools."""
683+
684+
def double_number(n: int) -> int:
685+
"""Double a number."""
686+
return 10
687+
688+
manager = ToolManager()
689+
tool = manager.add_tool(double_number, invocation_modes=["sync", "async"], keep_alive=1)
690+
691+
# Test that meta is populated and has the keepalive stashed in it
692+
expected_meta = {
693+
"_keep_alive": 1,
694+
}
695+
assert tool.meta == expected_meta
696+
697+
def test_tool_keep_alive_property_meta(self):
698+
"""Test that keep_alive property works correctly with existing metadata defined."""
699+
700+
def double_number(n: int) -> int:
701+
"""Double a number."""
702+
return 10
703+
704+
manager = ToolManager()
705+
tool = manager.add_tool(double_number, invocation_modes=["async"], keep_alive=1, meta={"foo": "bar"})
706+
707+
# Test that meta is populated and has the keepalive stashed in it
708+
expected_meta = {
709+
"foo": "bar",
710+
"_keep_alive": 1,
711+
}
712+
assert tool.meta == expected_meta
713+
636714
@pytest.mark.anyio
637715
async def test_tool_with_dict_str_any_output(self):
638716
"""Test tool with dict[str, Any] return type."""

0 commit comments

Comments
 (0)