Files
cleveragents-core/features/steps/mcp_adapter_steps.py
T
freemo 7c2f5a1c76
CI / lint (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 58s
CI / security (pull_request) Successful in 1m0s
CI / quality (pull_request) Successful in 39s
CI / build (pull_request) Successful in 24s
CI / helm (pull_request) Successful in 23s
CI / unit_tests (pull_request) Successful in 7m4s
CI / e2e_tests (pull_request) Successful in 17m25s
CI / integration_tests (pull_request) Successful in 22m49s
CI / docker (pull_request) Successful in 1m29s
CI / coverage (pull_request) Successful in 11m6s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 57m30s
fix(mcp): correct MCPToolResult.data type annotation for MCP 1.4.0 content list format
- What was implemented
  - Updated MCPToolResult.data docstring to document the normalisation behaviour, clarifying that MCP 1.4.0 list-format content is normalised to a dict with a "content" key.
  - Updated MCPToolAdapter.invoke() success path to normalise MCP 1.4.0 list-format content: when content is a list (as per MCP 1.4.0 spec), it is wrapped as {"content": [...]} so MCPToolResult.data is always dict[str, Any].
  - Updated MockMCPTransport.call() to return MCP 1.4.0-compliant list-format content: invoke_results are now wrapped as [{"type": "text", "text": json.dumps(payload)}] instead of the non-standard dict format.
  - Added json import to mock_mcp_transport.py to support JSON payload encoding.
  - Updated existing mcp_adapter.feature scenario "Invoke a discovered tool successfully" to check for "content" key instead of "id" (since mock now returns MCP 1.4.0 list format).
  - Added two new Behave scenarios: "Invoke tool returns MCP 1.4.0 list-format content normalised to dict" and "Invoke tool with MCP 1.4.0 content list stores content items".
  - Added corresponding step definitions for the new scenarios.
  - All 14,418 existing scenarios continue to pass; 2 new scenarios added; typecheck passes with 0 errors; lint passes.

- Why this approach
  - Design decision: Normalize to dict (Option B) to maintain a consistent MCPToolResult.data type of dict[str, Any] and avoid breaking downstream code that accesses result.data["key"].
  - Fallback path handles non-standard server responses gracefully, ensuring robustness when servers deviate from MCP 1.4.0 spec.

- Technical approach and affected components
  - Core: MCPToolResult data handling and MCPToolAdapter.invoke() logic
  - Mocks: mock_mcp_transport.py updated to emit MCP 1.4.0 list-format content
  - Tests: updated mcp_adapter.feature expectations; added two Behave scenarios with new step definitions
  - Dependencies: added import json to mock_mcp_transport.py

- Verification
  - Comprehensive test suites: 14,418 existing scenarios pass
  - 2 new scenarios added and pass
  - Typechecking: 0 errors
  - Linting: passes

ISSUES CLOSED: #2743
2026-04-05 07:48:02 +00:00

572 lines
21 KiB
Python

"""Step definitions for features/mcp_adapter.feature."""
from __future__ import annotations
import json
import threading
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.mcp.adapter import (
MCPServerConfig,
MCPToolAdapter,
MCPToolFilter,
)
from cleveragents.tool.registry import ToolRegistry
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 {},
}
# ---------------------------------------------------------------
# Adapter Lifecycle
# ---------------------------------------------------------------
@given('an MCP server config for "{name}" with stdio transport')
def step_mcp_config_stdio(context: Context, name: str) -> None:
context.mcp_config = MCPServerConfig(
name=name,
transport="stdio",
command="echo",
args=["hello"],
)
@given('an MCP server config for "{name}" with sse transport and url "{url}"')
def step_mcp_config_sse(context: Context, name: str, url: str) -> None:
context.mcp_config = MCPServerConfig(name=name, transport="sse", url=url)
@given("an MCP adapter with a mock transport")
def step_mcp_adapter_mock(context: Context) -> None:
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport()
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
@given("a connected MCP adapter with a mock transport")
def step_mcp_adapter_connected(context: Context) -> None:
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport()
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
@given("a connected MCP adapter with {count:d} mock tools")
def step_mcp_adapter_with_tools(context: Context, count: int) -> None:
tools = [_mock_tool(f"tool_{i}") for i in range(count)]
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport(tools=tools)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
@given('a connected MCP adapter with a callable mock tool "{tool_name}"')
def step_mcp_adapter_callable_tool(context: Context, tool_name: str) -> None:
tools = [_mock_tool(tool_name)]
results = {tool_name: {"id": 42, "status": "created"}}
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport(tools=tools, invoke_results=results)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
context.mcp_adapter.discover_tools()
@given('a connected MCP adapter with a schema-validated mock tool "{tool_name}"')
def step_mcp_adapter_schema_tool(context: Context, tool_name: str) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {
"title": {"type": "string"},
"body": {"type": "string"},
},
"required": ["title", "body"],
}
tools = [_mock_tool(tool_name, schema=schema)]
results = {tool_name: {"id": 1}}
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport(tools=tools, invoke_results=results)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
context.mcp_adapter.discover_tools()
@given('a connected MCP adapter with a failing mock tool "{tool_name}"')
def step_mcp_adapter_failing_tool(context: Context, tool_name: str) -> None:
tools = [_mock_tool(tool_name)]
errors = {tool_name: "Internal server error"}
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport(tools=tools, invoke_errors=errors)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
context.mcp_adapter.discover_tools()
@given('a tool filter including only "{tool_name}"')
def step_tool_filter_include(context: Context, tool_name: str) -> None:
context.mcp_tool_filter = MCPToolFilter(include=[tool_name])
@given('a tool filter excluding "{tool_name}"')
def step_tool_filter_exclude(context: Context, tool_name: str) -> None:
context.mcp_tool_filter = MCPToolFilter(exclude=[tool_name])
@given("an empty MCP tool registry")
def step_empty_mcp_registry(context: Context) -> None:
context.mcp_registry = ToolRegistry()
@given("an MCP adapter with a transport that fails to connect")
def step_mcp_adapter_fail_connect(context: Context) -> None:
config = MCPServerConfig(name="bad-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport(fail_connect=True)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
@given("a connected MCP adapter with a tool that times out")
def step_mcp_adapter_timeout_tool(context: Context) -> None:
tools = [_mock_tool("slow_tool")]
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
context.mcp_transport = MockMCPTransport(tools=tools, timeout_tools={"slow_tool"})
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
context.mcp_adapter.discover_tools()
@given("a connected MCP adapter with a transport that errors on close")
def step_mcp_adapter_close_error(context: Context) -> None:
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
class _ErrorCloseTransport(MockMCPTransport):
def close(self) -> None:
msg = "Close failed"
raise OSError(msg)
context.mcp_transport = _ErrorCloseTransport()
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
@given('a connected MCP adapter with a raw list-content mock tool "{tool_name}"')
def step_mcp_adapter_raw_list_content_tool(context: Context, tool_name: str) -> None:
"""Adapter whose transport returns a raw MCP 1.4.0 list-format content response."""
tools = [_mock_tool(tool_name)]
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
class _ListContentTransport(MockMCPTransport):
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
if method == "tools/call":
return {
"content": [{"type": "text", "text": "fetched data"}],
}
return super().call(method, params)
context.mcp_transport = _ListContentTransport(tools=tools)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
context.mcp_adapter.discover_tools()
@given('a connected MCP adapter with a transport-error mock tool "{tool_name}"')
def step_mcp_adapter_transport_error_tool(context: Context, tool_name: str) -> None:
tools = [_mock_tool(tool_name)]
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
class _CrashTransport(MockMCPTransport):
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
if method == "tools/call":
msg = "Transport crashed unexpectedly"
raise RuntimeError(msg)
return super().call(method, params)
context.mcp_transport = _CrashTransport(tools=tools)
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
context.mcp_adapter.discover_tools()
@given('an MCP server config for "{name}" with stdio transport but no command')
def step_mcp_config_stdio_no_cmd(context: Context, name: str) -> None:
context.mcp_config = MCPServerConfig(name=name, transport="stdio")
@given('an MCP server config for "{name}" with sse transport but no url')
def step_mcp_config_sse_no_url(context: Context, name: str) -> None:
context.mcp_config = MCPServerConfig(name=name, transport="sse")
@given("a connected MCP adapter with a transport that fails to reconnect")
def step_mcp_adapter_fail_reconnect(context: Context) -> None:
"""Adapter starts connected but the transport will refuse the next connect."""
config = MCPServerConfig(name="flaky-server", transport="stdio", command="echo")
call_count: list[int] = [0]
class _FlakeyTransport(MockMCPTransport):
def connect(self, cfg: MCPServerConfig) -> dict[str, Any]:
call_count[0] += 1
if call_count[0] > 1:
msg = "Server unavailable after restart"
raise ConnectionRefusedError(msg)
return super().connect(cfg)
context.mcp_transport = _FlakeyTransport()
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context.mcp_adapter.connect()
@given("an MCP adapter with a transport that hangs on connect")
def step_mcp_adapter_hang_connect(context: Context) -> None:
"""Adapter whose transport blocks indefinitely during connect."""
config = MCPServerConfig(name="hang-server", transport="stdio", command="echo")
_hang_event = threading.Event()
class _HangTransport(MockMCPTransport):
def connect(self, cfg: MCPServerConfig) -> dict[str, Any]:
_hang_event.wait(timeout=10)
return super().connect(cfg)
context.mcp_transport = _HangTransport()
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
context._hang_event = _hang_event
# ---------------------------------------------------------------
# When Steps
# ---------------------------------------------------------------
@when("I create an MCP adapter from the config")
def step_create_adapter(context: Context) -> None:
context.mcp_error = None
try:
context.mcp_adapter = MCPToolAdapter.from_server_config(context.mcp_config)
except Exception as exc:
context.mcp_error = str(exc)
@when("I create an MCP adapter from the config expecting validation error")
def step_create_adapter_fail(context: Context) -> None:
context.mcp_error = None
try:
context.mcp_adapter = MCPToolAdapter.from_server_config(context.mcp_config)
except Exception as exc:
context.mcp_error = str(exc)
@when("I connect the adapter")
def step_connect_adapter(context: Context) -> None:
context.mcp_error = None
context.mcp_adapter.connect()
@when("I connect the adapter expecting error")
def step_connect_adapter_fail(context: Context) -> None:
context.mcp_error = None
try:
context.mcp_adapter.connect()
except Exception as exc:
context.mcp_error = str(exc)
@when("I disconnect the adapter")
def step_disconnect_adapter(context: Context) -> None:
context.mcp_adapter.disconnect()
@when("I reconnect the adapter")
def step_reconnect_adapter(context: Context) -> None:
context.mcp_error = None
context.mcp_adapter.reconnect()
@when("I reconnect the adapter expecting error")
def step_reconnect_adapter_fail(context: Context) -> None:
context.mcp_error = None
try:
context.mcp_adapter.reconnect()
except Exception as exc:
context.mcp_error = str(exc)
@when("I connect the adapter with timeout {secs:f} expecting error")
def step_connect_with_timeout_fail(context: Context, secs: float) -> None:
context.mcp_error = None
try:
context.mcp_adapter.connect(timeout=secs)
except Exception as exc:
context.mcp_error = str(exc)
@when("I discover tools from the adapter")
def step_discover_tools(context: Context) -> None:
context.mcp_discovered = context.mcp_adapter.discover_tools()
@when("I discover tools from the adapter with filter")
def step_discover_tools_filtered(context: Context) -> None:
context.mcp_discovered = context.mcp_adapter.discover_tools(
tool_filter=context.mcp_tool_filter
)
@when("I discover tools expecting an error")
def step_discover_tools_error(context: Context) -> None:
context.mcp_error = None
try:
context.mcp_adapter.discover_tools()
except Exception as exc:
context.mcp_error = str(exc)
@when('I invoke "{tool_name}" with arguments {args_json}')
def step_invoke_tool(context: Context, tool_name: str, args_json: str) -> None:
arguments = json.loads(args_json)
context.mcp_invoke_result = context.mcp_adapter.invoke(tool_name, arguments)
@when('I invoke MCP tool "{tool_name}" while disconnected')
def step_invoke_not_connected(context: Context, tool_name: str) -> None:
context.mcp_error = None
try:
context.mcp_adapter.invoke(tool_name, {})
except Exception as exc:
context.mcp_error = str(exc)
@when('I register MCP tools in the registry with namespace "{namespace}"')
def step_register_tools(context: Context, namespace: str) -> None:
context.mcp_registered = context.mcp_adapter.register_tools(
registry=context.mcp_registry,
namespace=namespace,
)
@when('the MCP server adds a new tool "{tool_name}"')
def step_server_adds_tool(context: Context, tool_name: str) -> None:
context.mcp_transport.add_tool(_mock_tool(tool_name))
@when('I re-register MCP tools in the registry with namespace "{namespace}"')
def step_reregister_tools(context: Context, namespace: str) -> None:
context.mcp_registered = context.mcp_adapter.register_tools(
registry=context.mcp_registry,
namespace=namespace,
)
# ---------------------------------------------------------------
# Then Steps
# ---------------------------------------------------------------
@then('the adapter server name should be "{name}"')
def step_adapter_name(context: Context, name: str) -> None:
assert context.mcp_adapter.server_name == name
@then('the adapter transport should be "{transport}"')
def step_adapter_transport(context: Context, transport: str) -> None:
assert context.mcp_adapter.transport_type == transport
@then("the adapter should not be connected")
def step_adapter_not_connected(context: Context) -> None:
assert not context.mcp_adapter.is_connected
@then("the adapter should be connected")
def step_adapter_connected(context: Context) -> None:
assert context.mcp_adapter.is_connected
@then("the adapter server capabilities should be set")
def step_adapter_capabilities(context: Context) -> None:
assert context.mcp_adapter.capabilities
@then("the adapter should have {count:d} discovered tools")
def step_adapter_tool_count(context: Context, count: int) -> None:
assert len(context.mcp_discovered) == count, (
f"Expected {count} tools, got {len(context.mcp_discovered)}"
)
@then("discovered tool {idx:d} should have a name")
def step_discovered_tool_name(context: Context, idx: int) -> None:
assert context.mcp_discovered[idx].name
@then("discovered tool {idx:d} should have an input schema")
def step_discovered_tool_schema(context: Context, idx: int) -> None:
assert context.mcp_discovered[idx].input_schema is not None
@then("the invocation should succeed")
def step_invoke_success(context: Context) -> None:
assert context.mcp_invoke_result.success, (
f"Expected success, got error: {context.mcp_invoke_result.error}"
)
@then("the invocation should fail")
def step_invoke_fail(context: Context) -> None:
assert not context.mcp_invoke_result.success
@then('the invocation result should contain key "{key}"')
def step_invoke_result_key(context: Context, key: str) -> None:
assert key in context.mcp_invoke_result.data, (
f"Key '{key}' not in result: {context.mcp_invoke_result.data}"
)
@then('the invocation result data should have key "{key}"')
def step_invoke_result_data_key(context: Context, key: str) -> None:
assert isinstance(context.mcp_invoke_result.data, dict), (
f"Expected data to be a dict, got {type(context.mcp_invoke_result.data)}"
)
assert key in context.mcp_invoke_result.data, (
f"Key '{key}' not in result data: {context.mcp_invoke_result.data}"
)
@then("the invocation result content should be a list")
def step_invoke_result_content_is_list(context: Context) -> None:
content = context.mcp_invoke_result.data.get("content")
assert isinstance(content, list), (
f"Expected 'content' to be a list, got {type(content)}: {content}"
)
@then("the invocation result content list should have {count:d} item")
def step_invoke_result_content_count(context: Context, count: int) -> None:
content = context.mcp_invoke_result.data.get("content")
assert isinstance(content, list), (
f"Expected 'content' to be a list, got {type(content)}"
)
assert len(content) == count, (
f"Expected {count} content item(s), got {len(content)}: {content}"
)
@then('the invocation error should mention "{text}"')
def step_invoke_error_mention(context: Context, text: str) -> None:
assert context.mcp_invoke_result.error is not None
assert text.lower() in context.mcp_invoke_result.error.lower(), (
f"Error '{context.mcp_invoke_result.error}' does not mention '{text}'"
)
@then('the adapter error should mention "{text}"')
def step_adapter_error_mention(context: Context, text: str) -> None:
assert context.mcp_error is not None, "Expected error but none occurred"
assert text.lower() in context.mcp_error.lower(), (
f"Error '{context.mcp_error}' does not mention '{text}'"
)
@then("the registry should have {count:d} tools")
def step_registry_count(context: Context, count: int) -> None:
all_tools = context.mcp_registry.list_tools()
assert len(all_tools) == count, f"Expected {count} tools, got {len(all_tools)}"
@then('registry tool {idx:d} name should start with "{prefix}"')
def step_registry_tool_prefix(context: Context, idx: int, prefix: str) -> None:
all_tools = context.mcp_registry.list_tools()
assert all_tools[idx].name.startswith(prefix)
@then('calling registry tool "{tool_name}" should succeed')
def step_registry_tool_callable(context: Context, tool_name: str) -> None:
spec = context.mcp_registry.get(tool_name)
assert spec is not None, f"Tool '{tool_name}' not in registry"
result = spec.handler()
assert isinstance(result, dict)
@then('calling registry tool "{tool_name}" should return an error')
def step_registry_tool_error(context: Context, tool_name: str) -> None:
spec = context.mcp_registry.get(tool_name)
assert spec is not None, f"Tool '{tool_name}' not in registry"
result = spec.handler()
assert isinstance(result, dict)
assert "error" in result
@then("the adapter discovered_tools property should have {count:d} items")
def step_adapter_discovered_tools_property(context: Context, count: int) -> None:
dt = context.mcp_adapter.discovered_tools
assert len(dt) == count, f"Expected {count}, got {len(dt)}"
# ---------------------------------------------------------------
# Capability Inference Steps
# ---------------------------------------------------------------
@when('I infer capabilities for tool name "{tool_name}"')
def step_infer_capabilities(context: Context, tool_name: str) -> None:
context.inferred_caps = MCPToolAdapter.infer_capabilities(tool_name)
@then("the inferred capabilities should have read_only true")
def step_inferred_read_only_true(context: Context) -> None:
assert context.inferred_caps["read_only"] is True
@then("the inferred capabilities should have read_only false")
def step_inferred_read_only_false(context: Context) -> None:
assert context.inferred_caps["read_only"] is False
@then("the inferred capabilities should have writes true")
def step_inferred_writes_true(context: Context) -> None:
assert context.inferred_caps["writes"] is True
@then("the inferred capabilities should have writes false")
def step_inferred_writes_false(context: Context) -> None:
assert context.inferred_caps["writes"] is False
@then('registry tool "{tool_name}" should have read_only capability')
def step_registry_tool_read_only(context: Context, tool_name: str) -> None:
spec = context.mcp_registry.get(tool_name)
assert spec is not None, f"Tool '{tool_name}' not in registry"
assert spec.capabilities.read_only is True
@then('registry tool "{tool_name}" should have writes capability')
def step_registry_tool_writes(context: Context, tool_name: str) -> None:
spec = context.mcp_registry.get(tool_name)
assert spec is not None, f"Tool '{tool_name}' not in registry"
assert spec.capabilities.writes is True
@then('registry tool "{tool_name}" should have source "{source}"')
def step_registry_tool_source(context: Context, tool_name: str, source: str) -> None:
spec = context.mcp_registry.get(tool_name)
assert spec is not None, f"Tool '{tool_name}' not in registry"
assert spec.source == source, f"Expected source '{source}', got '{spec.source}'"
@then('registry tool "{tool_name}" should have checkpointable false')
def step_registry_tool_checkpointable_false(context: Context, tool_name: str) -> None:
spec = context.mcp_registry.get(tool_name)
assert spec is not None, f"Tool '{tool_name}' not in registry"
assert spec.capabilities.checkpointable is False, (
f"Expected checkpointable=False, got {spec.capabilities.checkpointable}"
)