Files
cleveragents-core/features/steps/mcp_lifecycle_steps.py
T
freemo 81c2878ec8
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / helm (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 34s
CI / lint (pull_request) Successful in 3m19s
CI / typecheck (pull_request) Successful in 4m6s
CI / security (pull_request) Successful in 4m7s
CI / unit_tests (pull_request) Successful in 7m4s
CI / integration_tests (pull_request) Successful in 7m11s
CI / docker (pull_request) Successful in 1m35s
CI / coverage (pull_request) Failing after 8m51s
CI / e2e_tests (pull_request) Failing after 17m45s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-regression (pull_request) Successful in 54m58s
feat(mcp): implement lazy start/auto-stop and sandbox path rewriting for MCP tools
Add lifecycle management and sandbox support for MCP tools:

- McpClient: lazy start (server starts on first call_tool()),
  configurable idle timeout with auto-stop, health monitoring
  with automatic restart on server crash
- McpRegistry: namespace-isolated tracking of multiple MCP servers
  with independent lifecycles
- SandboxPathRewriter: bi-directional file path rewriting between
  host and sandbox workspaces using PathMapper
- MCPCapabilityMetadata: structured exposure of full MCP server
  capabilities (tools, resources, prompts)
- MCPToolDescriptor: extended with annotations field
- MCPToolAdapter: capability_metadata property, enhanced source_metadata
  with server capabilities and tool annotations

BDD scenarios:
- 16 lifecycle scenarios (lazy start, auto-stop, health check, registry)
- 10 sandbox path rewriting scenarios (arguments, responses, roundtrip)
- All 42 existing MCP adapter scenarios continue to pass

ISSUES CLOSED: #938
2026-03-30 22:42:15 +00:00

343 lines
12 KiB
Python

"""Step definitions for features/mcp_lifecycle.feature."""
from __future__ import annotations
import json
import time
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.mcp.adapter import MCPServerConfig
from cleveragents.mcp.client import McpClient, McpClientConfig
from cleveragents.mcp.registry import McpRegistry
from features.mocks.mock_mcp_transport import MockMCPTransport
def _mock_tool(name: str, desc: str = "", schema: dict | None = None) -> dict:
return {
"name": name,
"description": desc or f"Mock tool {name}",
"inputSchema": schema or {},
}
# ---------------------------------------------------------------
# Given Steps
# ---------------------------------------------------------------
@given('an MCP client config for "{name}" with lazy start enabled')
def step_mcp_client_config_lazy(context: Context, name: str) -> None:
context.mcp_server_config = MCPServerConfig(
name=name, transport="stdio", command="echo"
)
context.mcp_client_config = McpClientConfig(
server=context.mcp_server_config,
lazy_start=True,
idle_timeout_seconds=0,
health_check_interval_seconds=0,
)
@given('an MCP client config for "{name}" with lazy start disabled')
def step_mcp_client_config_no_lazy(context: Context, name: str) -> None:
context.mcp_server_config = MCPServerConfig(
name=name, transport="stdio", command="echo"
)
context.mcp_client_config = McpClientConfig(
server=context.mcp_server_config,
lazy_start=False,
idle_timeout_seconds=0,
health_check_interval_seconds=0,
)
@given('an MCP client config for "{name}" with idle timeout {secs:f} seconds')
def step_mcp_client_config_idle(context: Context, name: str, secs: float) -> None:
context.mcp_server_config = MCPServerConfig(
name=name, transport="stdio", command="echo"
)
context.mcp_client_config = McpClientConfig(
server=context.mcp_server_config,
lazy_start=True,
idle_timeout_seconds=secs,
health_check_interval_seconds=0,
)
@given('an MCP client config for "{name}" with health check interval {secs:f} seconds')
def step_mcp_client_config_health(context: Context, name: str, secs: float) -> None:
context.mcp_server_config = MCPServerConfig(
name=name, transport="stdio", command="echo"
)
context.mcp_client_config = McpClientConfig(
server=context.mcp_server_config,
lazy_start=True,
idle_timeout_seconds=0,
health_check_interval_seconds=secs,
auto_restart=True,
)
@given('a mock MCP transport with tool "{tool_name}"')
def step_mock_transport_with_tool(context: Context, tool_name: str) -> None:
tools = [_mock_tool(tool_name)]
results = {tool_name: {"result": "ok"}}
context.mcp_lifecycle_transport = MockMCPTransport(
tools=tools, invoke_results=results
)
@given('a mock MCP transport with tool "{tool_name}" that crashes after {count:d} call')
def step_mock_transport_crash(context: Context, tool_name: str, count: int) -> None:
"""Transport that works for N calls then starts failing health checks."""
tools = [_mock_tool(tool_name)]
results = {tool_name: {"result": "ok"}}
call_counter: list[int] = [0]
crash_active: list[bool] = [False]
class _CrashAfterNTransport(MockMCPTransport):
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
# Reset crash state and counter on reconnect so the
# server can recover after a restart.
crash_active[0] = False
call_counter[0] = 0
return super().connect(config)
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
if method == "tools/list":
call_counter[0] += 1
if call_counter[0] > count:
crash_active[0] = True
if crash_active[0]:
msg = "Server crashed"
raise ConnectionError(msg)
return super().call(method, params)
context.mcp_lifecycle_transport = _CrashAfterNTransport(
tools=tools, invoke_results=results
)
@given("a mock MCP transport with full capabilities")
def step_mock_transport_full_caps(context: Context) -> None:
tools = [_mock_tool("test_tool")]
class _FullCapsTransport(MockMCPTransport):
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
self._connected = True
return {
"capabilities": {
"tools": True,
"resources": True,
"prompts": True,
}
}
context.mcp_lifecycle_transport = _FullCapsTransport(tools=tools)
@given("an MCP registry")
def step_mcp_registry(context: Context) -> None:
context.mcp_lifecycle_registry = McpRegistry()
@given("an MCP registry with {count:d} running servers")
def step_mcp_registry_running(context: Context, count: int) -> None:
registry = McpRegistry()
for i in range(count):
server_name = f"server-{i}"
tools = [_mock_tool(f"tool_{i}")]
transport = MockMCPTransport(tools=tools)
config = McpClientConfig(
server=MCPServerConfig(name=server_name, transport="stdio", command="echo"),
lazy_start=True,
idle_timeout_seconds=0,
health_check_interval_seconds=0,
)
client = registry.register(config, namespace=f"ns-{i}", transport=transport)
client.start()
context.mcp_lifecycle_registry = registry
# ---------------------------------------------------------------
# When Steps
# ---------------------------------------------------------------
@when("I create an MCP client")
def step_create_mcp_client(context: Context) -> None:
context.mcp_lifecycle_client = McpClient(
config=context.mcp_client_config,
transport=context.mcp_lifecycle_transport,
)
context.mcp_lifecycle_error = None
@when('I call MCP tool "{tool_name}" with arguments {args_json}')
def step_call_mcp_tool(context: Context, tool_name: str, args_json: str) -> None:
arguments = json.loads(args_json)
context.mcp_lifecycle_result = context.mcp_lifecycle_client.call_tool(
tool_name, arguments
)
@when('I call MCP tool "{tool_name}" expecting a runtime error')
def step_call_mcp_tool_error(context: Context, tool_name: str) -> None:
context.mcp_lifecycle_error = None
try:
context.mcp_lifecycle_client.call_tool(tool_name, {})
except Exception as exc:
context.mcp_lifecycle_error = str(exc)
@when("I explicitly start the MCP client")
def step_start_mcp_client(context: Context) -> None:
context.mcp_lifecycle_client.start()
@when("I shutdown the MCP client")
def step_shutdown_mcp_client(context: Context) -> None:
context.mcp_lifecycle_client.shutdown()
@when("I wait {secs:f} seconds")
def step_wait_seconds(context: Context, secs: float) -> None:
# Use the original sleep if the test environment has patched
# time.sleep (e.g. _install_fast_sleep_patch caps at 10 ms).
# Timer-based lifecycle tests need real wall-clock advancement.
original_sleep = getattr(time, "_original_sleep", time.sleep)
original_sleep(secs)
@when('I register the MCP client in namespace "{namespace}"')
def step_register_mcp_client(context: Context, namespace: str) -> None:
context.mcp_lifecycle_registry.register(
config=context.mcp_client_config,
namespace=namespace,
transport=context.mcp_lifecycle_transport,
)
@when('I register another MCP client config for "{name}" in namespace "{namespace}"')
def step_register_another_mcp_client(
context: Context, name: str, namespace: str
) -> None:
config = McpClientConfig(
server=MCPServerConfig(name=name, transport="stdio", command="echo"),
lazy_start=True,
idle_timeout_seconds=0,
health_check_interval_seconds=0,
)
tools = [_mock_tool("default_tool")]
transport = MockMCPTransport(tools=tools)
context.mcp_lifecycle_registry.register(
config=config, namespace=namespace, transport=transport
)
@when(
'I call tool "{tool_name}" on MCP namespace "{namespace}" with arguments {args_json}'
)
def step_registry_call_tool(
context: Context, tool_name: str, namespace: str, args_json: str
) -> None:
arguments = json.loads(args_json)
context.mcp_lifecycle_result = context.mcp_lifecycle_registry.call_tool(
namespace, tool_name, arguments
)
@when("I shutdown all MCP servers")
def step_shutdown_all(context: Context) -> None:
context.mcp_lifecycle_registry.shutdown_all()
# ---------------------------------------------------------------
# Then Steps
# ---------------------------------------------------------------
@then('the MCP client state should be "{state}"')
def step_mcp_client_state(context: Context, state: str) -> None:
actual = context.mcp_lifecycle_client.state
assert actual == state, f"Expected state '{state}', got '{actual}'"
@then("the MCP client should not be connected")
def step_mcp_client_not_connected(context: Context) -> None:
assert not context.mcp_lifecycle_client.is_connected
@then("the MCP client should be connected")
def step_mcp_client_connected(context: Context) -> None:
assert context.mcp_lifecycle_client.is_connected
@then('the MCP client error should mention "{text}"')
def step_mcp_client_error_mention(context: Context, text: str) -> None:
assert context.mcp_lifecycle_error is not None, "Expected error but none occurred"
assert text.lower() in context.mcp_lifecycle_error.lower(), (
f"Error '{context.mcp_lifecycle_error}' does not mention '{text}'"
)
@then("the MCP client restart count should be greater than {count:d}")
def step_mcp_restart_count_gt(context: Context, count: int) -> None:
actual = context.mcp_lifecycle_client.restart_count
assert actual > count, f"Expected restart count > {count}, got {actual}"
@then("the MCP client health check failures should be {count:d}")
def step_mcp_health_failures(context: Context, count: int) -> None:
actual = context.mcp_lifecycle_client.health_check_failures
assert actual == count, f"Expected {count} health failures, got {actual}"
@then("the MCP registry should have {count:d} namespaces")
def step_registry_namespace_count(context: Context, count: int) -> None:
actual = len(context.mcp_lifecycle_registry.list_namespaces())
assert actual == count, f"Expected {count} namespaces, got {actual}"
@then('MCP namespace "{namespace}" should not be connected')
def step_namespace_not_connected(context: Context, namespace: str) -> None:
client = context.mcp_lifecycle_registry.get(namespace)
assert client is not None, f"Namespace '{namespace}' not found"
assert not client.is_connected
@then('MCP namespace "{namespace}" should be connected')
def step_namespace_connected(context: Context, namespace: str) -> None:
client = context.mcp_lifecycle_registry.get(namespace)
assert client is not None, f"Namespace '{namespace}' not found"
assert client.is_connected
@then("all MCP servers should be stopped")
def step_all_stopped(context: Context) -> None:
for ns in context.mcp_lifecycle_registry.list_namespaces():
client = context.mcp_lifecycle_registry.get(ns)
assert client is not None
assert not client.is_connected, f"Server in namespace '{ns}' still connected"
@then("the MCP adapter capability metadata should have tools true")
def step_cap_tools_true(context: Context) -> None:
meta = context.mcp_lifecycle_client.adapter.capability_metadata
assert meta.tools is True, f"Expected tools=True, got {meta.tools}"
@then("the MCP adapter capability metadata should have resources true")
def step_cap_resources_true(context: Context) -> None:
meta = context.mcp_lifecycle_client.adapter.capability_metadata
assert meta.resources is True, f"Expected resources=True, got {meta.resources}"
@then("the MCP adapter capability metadata should have prompts true")
def step_cap_prompts_true(context: Context) -> None:
meta = context.mcp_lifecycle_client.adapter.capability_metadata
assert meta.prompts is True, f"Expected prompts=True, got {meta.prompts}"