fix(mcp): correct MCPToolResult.data type annotation for MCP 1.4.0 content list format #3032

Merged
freemo merged 1 commits from fix/mcp-tool-result-data-type into master 2026-04-05 21:16:28 +00:00
4 changed files with 96 additions and 5 deletions
+19 -1
View File
@@ -83,7 +83,7 @@ Feature: MCP Tool Adapter
Given a connected MCP adapter with a callable mock tool "create_issue"
When I invoke "create_issue" with arguments {"title": "Bug", "body": "Fix it"}
Then the invocation should succeed
And the invocation result should contain key "id"
And the invocation result should contain key "content"
Scenario: Invoke with valid input schema passes validation
Given a connected MCP adapter with a schema-validated mock tool "create_issue"
@@ -209,6 +209,24 @@ Feature: MCP Tool Adapter
Then the invocation should fail
And the invocation error should mention "server error"
# -------------------------------------------------------------------
# MCP 1.4.0 Content List Format
# -------------------------------------------------------------------
Scenario: Invoke tool returns MCP 1.4.0 list-format content normalised to dict
Given a connected MCP adapter with a callable mock tool "get_status"
When I invoke "get_status" with arguments {}
Then the invocation should succeed
And the invocation result data should have key "content"
And the invocation result content should be a list
Scenario: Invoke tool with MCP 1.4.0 content list stores content items
Given a connected MCP adapter with a raw list-content mock tool "fetch_data"
When I invoke "fetch_data" with arguments {}
Then the invocation should succeed
And the invocation result data should have key "content"
And the invocation result content list should have 1 item
# -------------------------------------------------------------------
# Error Classification
# -------------------------------------------------------------------
+11 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from typing import Any
from cleveragents.mcp.adapter import MCPServerConfig, MCPTransport
@@ -57,9 +58,17 @@ class MockMCPTransport(MCPTransport):
}
if tool_name in self._invoke_results:
return {"content": self._invoke_results[tool_name]}
# MCP 1.4.0: ``content`` is a list of ContentItem dicts.
# Wrap the caller-supplied result dict as a text ContentItem
# so the mock accurately reflects real server behaviour.
payload = self._invoke_results[tool_name]
content_item: dict[str, Any] = {
"type": "text",
"text": json.dumps(payload),
}
return {"content": [content_item]}
return {"content": {"result": "ok"}}
return {"content": [{"type": "text", "text": "ok"}]}
return {}
+49
View File
@@ -157,6 +157,26 @@ def step_mcp_adapter_close_error(context: Context) -> None:
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)]
@@ -411,6 +431,35 @@ def step_invoke_result_key(context: Context, key: str) -> None:
)
@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
+17 -2
View File
@@ -123,7 +123,10 @@ class MCPToolResult(BaseModel):
Attributes:
success: Whether the invocation succeeded.
data: Result payload from the MCP server.
data: Result payload from the MCP server, normalised to a dict.
On success the MCP 1.4.0 ``content`` list is wrapped under the
``"content"`` key so callers always receive a ``dict[str, Any]``
regardless of the raw server response format.
error: Error message on failure.
duration_ms: Wall-clock execution time in milliseconds.
"""
@@ -525,9 +528,21 @@ class MCPToolAdapter:
duration_ms=elapsed,
)
# MCP 1.4.0 returns ``content`` as a list of ContentItem dicts
# (e.g. ``[{"type": "text", "text": "..."}]``). Normalise to a
# plain dict so that ``MCPToolResult.data`` is always
# ``dict[str, Any]`` and downstream code can rely on dict access.
raw_content = result.get("content", [])
if isinstance(raw_content, list):
normalised: dict[str, Any] = {"content": raw_content}
elif isinstance(raw_content, dict):
normalised = raw_content
else:
# Unexpected type — wrap it so the dict contract holds.
normalised = {"content": raw_content}
return MCPToolResult(
success=True,
data=result.get("content", result),
data=normalised,
duration_ms=elapsed,
)