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
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
343 lines
12 KiB
Python
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}"
|