Files
cleveragents-core/features/steps/mcp_sandbox_rewrite_steps.py
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

139 lines
5.0 KiB
Python

"""Step definitions for features/mcp_sandbox_rewrite.feature."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.mcp.sandbox import SandboxPathRewriter
# ---------------------------------------------------------------
# Given Steps
# ---------------------------------------------------------------
@given(
'a sandbox path rewriter with host root "{host_root}" and sandbox root "{sandbox_root}"'
)
def step_sandbox_rewriter(context: Context, host_root: str, sandbox_root: str) -> None:
context.sandbox_rewriter = SandboxPathRewriter(
host_root=host_root, sandbox_root=sandbox_root
)
# ---------------------------------------------------------------
# When Steps
# ---------------------------------------------------------------
@when('I rewrite tool arguments with key "{key}" and value "{value}"')
def step_rewrite_arguments_kv(context: Context, key: str, value: str) -> None:
arguments = {key: value}
context.sandbox_rewritten_args = context.sandbox_rewriter.rewrite_arguments(
arguments
)
@when(
'I rewrite nested tool arguments with outer key "{outer}" inner key "{inner}" and value "{value}"'
)
def step_rewrite_nested_arguments(
context: Context, outer: str, inner: str, value: str
) -> None:
arguments = {outer: {inner: value}}
context.sandbox_rewritten_args = context.sandbox_rewriter.rewrite_arguments(
arguments
)
@when('I rewrite tool arguments with key "{key}" and path list "{paths}"')
def step_rewrite_arguments_list(context: Context, key: str, paths: str) -> None:
path_list = paths.split(",")
arguments = {key: path_list}
context.sandbox_rewritten_args = context.sandbox_rewriter.rewrite_arguments(
arguments
)
@when('I rewrite tool response with key "{key}" and value "{value}"')
def step_rewrite_response_kv(context: Context, key: str, value: str) -> None:
response = {key: value}
context.sandbox_rewritten_resp = context.sandbox_rewriter.rewrite_response(response)
@when(
'I rewrite nested tool response with outer key "{outer}" inner key "{inner}" and value "{value}"'
)
def step_rewrite_nested_response(
context: Context, outer: str, inner: str, value: str
) -> None:
response = {outer: {inner: value}}
context.sandbox_rewritten_resp = context.sandbox_rewriter.rewrite_response(response)
@when('I roundtrip rewrite tool arguments with key "{key}" and value "{value}"')
def step_roundtrip_rewrite(context: Context, key: str, value: str) -> None:
arguments = {key: value}
rewritten = context.sandbox_rewriter.rewrite_arguments(arguments)
# Simulate the sandbox returning the rewritten path
context.sandbox_roundtrip_result = context.sandbox_rewriter.rewrite_response(
rewritten
)
# ---------------------------------------------------------------
# Then Steps
# ---------------------------------------------------------------
@then('the rewritten arguments should have "{key}" equal to "{expected}"')
def step_rewritten_arg_equal(context: Context, key: str, expected: str) -> None:
actual = context.sandbox_rewritten_args.get(key)
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then('the rewritten nested argument "{path}" should equal "{expected}"')
def step_rewritten_nested_arg(context: Context, path: str, expected: str) -> None:
parts = path.split(".")
value: Any = context.sandbox_rewritten_args
for part in parts:
value = value[part]
assert value == expected, f"Expected '{expected}', got '{value}'"
@then('the rewritten argument "{key}" should be a list with {count:d} rewritten paths')
def step_rewritten_arg_list(context: Context, key: str, count: int) -> None:
value = context.sandbox_rewritten_args.get(key)
assert isinstance(value, list), f"Expected list, got {type(value)}"
assert len(value) == count, f"Expected {count} items, got {len(value)}"
for item in value:
assert item.startswith("/workspace"), f"Path not rewritten: {item}"
@then('the rewritten response should have "{key}" equal to "{expected}"')
def step_rewritten_resp_equal(context: Context, key: str, expected: str) -> None:
actual = context.sandbox_rewritten_resp.get(key)
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then('the rewritten nested response "{path}" should equal "{expected}"')
def step_rewritten_nested_resp(context: Context, path: str, expected: str) -> None:
parts = path.split(".")
value: Any = context.sandbox_rewritten_resp
for part in parts:
value = value[part]
assert value == expected, f"Expected '{expected}', got '{value}'"
@then('the roundtrip path should equal "{expected}"')
def step_roundtrip_equal(context: Context, expected: str) -> None:
result = context.sandbox_roundtrip_result
for v in result.values():
if isinstance(v, str):
assert v == expected, f"Expected '{expected}', got '{v}'"
return
msg = f"No string value found in roundtrip result: {result}"
raise AssertionError(msg)