@@ -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
753872class TestServerResources :
754873 @pytest .mark .anyio
0 commit comments