diff --git a/src/adcp/server/test_controller.py b/src/adcp/server/test_controller.py index 18c1ce720..9a2c4b535 100644 --- a/src/adcp/server/test_controller.py +++ b/src/adcp/server/test_controller.py @@ -683,7 +683,7 @@ async def force_account_status(self, account_id, status): from adcp.server.base import ToolContext as _ToolContext from adcp.server.serve import RequestMetadata as _RequestMetadata - async def comply_test_controller(**kwargs: Any) -> str: + async def comply_test_controller(**kwargs: Any) -> dict[str, Any]: context: _ToolContext | None = None if context_factory is not None: meta = _RequestMetadata(tool_name="comply_test_controller", transport="mcp") @@ -693,8 +693,7 @@ async def comply_test_controller(**kwargs: Any) -> str: "context_factory for comply_test_controller returned " f"{type(context).__name__}, not a ToolContext instance" ) - result = await _handle_test_controller(store, kwargs, context=context) - return json.dumps(result) + return await _handle_test_controller(store, kwargs, context=context) tool = Tool.from_function( comply_test_controller, diff --git a/tests/test_test_controller_context.py b/tests/test_test_controller_context.py index f45ba11f1..616e8468e 100644 --- a/tests/test_test_controller_context.py +++ b/tests/test_test_controller_context.py @@ -318,14 +318,11 @@ def build_context(meta: RequestMetadata) -> ToolContext: tool = mcp._tool_manager._tools["comply_test_controller"] # FastMCP's tool wrapper takes the function args as kwargs. fn = tool.fn # type: ignore[attr-defined] - result_json = await fn( + result = await fn( scenario="force_account_status", params={"account_id": "acc-1", "status": "suspended"}, ) - import json - - result = json.loads(result_json) assert result["success"] is True assert result["current_state"] == "suspended" # The factory ran, built a ToolContext, and the store saw the header- @@ -340,6 +337,27 @@ def build_context(meta: RequestMetadata) -> ToolContext: assert received[0].metadata["tool_name"] == "comply_test_controller" +async def test_register_test_controller_list_scenarios_returns_dict(): + """Regression for #314 — comply_test_controller must return a dict (not a + JSON string) through the FastMCP registration path so the JS runner's + structuredContent unwrapper can read data.success and data.scenarios.""" + + class _Store(TestControllerStore): + async def force_account_status(self, account_id: str, status: str) -> dict[str, Any]: + return {"previous_state": "active", "current_state": status} + + mcp = create_mcp_server(_MinimalHandler(), name="test-agent") + register_test_controller(mcp, _Store()) + + tool = mcp._tool_manager._tools["comply_test_controller"] + fn = tool.fn # type: ignore[attr-defined] + result = await fn(scenario="list_scenarios") + + assert isinstance(result, dict), "must be a dict, not a JSON string" + assert result["success"] is True + assert "force_account_status" in result["scenarios"] + + async def test_register_test_controller_rejects_non_toolcontext_from_factory(): """Guard rail — a factory that returns a dict instead of a ToolContext fails loudly at call time, not deep inside the store."""