""" Regression tests for State Hub MCP write tools. These call the registered FastMCP tools in-process and monkeypatch only the thin HTTP helpers. That exercises FastMCP argument validation and the MCP wrapper logic without depending on a running State Hub API. """ from __future__ import annotations import json from typing import Any import mcp_server.server as server async def _call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: result = await server.mcp.call_tool(name, arguments) return json.loads(result.content[0].text) class TestMCPWriteTools: async def test_create_workstream_returns_rest_shape_and_emits_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) if path == "/workstreams": return { "id": "ws-1", "topic_id": body["topic_id"], "title": body["title"], "slug": body["slug"], "status": body["status"], } if path == "/progress": return {"id": "event-1", **body} raise AssertionError(f"unexpected POST {path}") monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "create_workstream", {"topic_id": "topic-1", "title": "MCP Reliable Write"}, ) assert body == { "id": "ws-1", "topic_id": "topic-1", "title": "MCP Reliable Write", "slug": "mcp-reliable-write", "status": "active", } assert [path for path, _ in calls] == ["/workstreams", "/progress"] assert calls[1][1]["workstream_id"] == "ws-1" assert calls[1][1]["event_type"] == "workstream_created" async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) if path == "/tasks": return { "id": "task-1", "workstream_id": body["workstream_id"], "title": body["title"], "priority": body["priority"], "status": "todo", } if path == "/progress": return {"id": "event-1", **body} raise AssertionError(f"unexpected POST {path}") monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "create_task", {"workstream_id": "ws-1", "title": "MCP task", "priority": "high"}, ) assert body == { "id": "task-1", "workstream_id": "ws-1", "title": "MCP task", "priority": "high", "status": "todo", } assert [path for path, _ in calls] == ["/tasks", "/progress"] assert calls[1][1]["task_id"] == "task-1" assert calls[1][1]["event_type"] == "task_created" async def test_update_task_status_api_error_is_clear_and_skips_progress(self, monkeypatch): post_calls: list[tuple[str, dict[str, Any]]] = [] def fake_patch(path: str, body: dict[str, Any]) -> dict[str, Any]: assert path == "/tasks/task-1" assert body["status"] == "done" return {"error": "API 404: task not found"} monkeypatch.setattr(server, "_patch", fake_patch) monkeypatch.setattr(server, "_post", lambda path, body: post_calls.append((path, body))) body = await _call_tool( "update_task_status", {"task_id": "task-1", "status": "done"}, ) assert body["tool"] == "update_task_status" assert body["error"] == "API 404: task not found" assert post_calls == [] async def test_update_task_status_returns_rest_shape_and_emits_progress(self, monkeypatch): post_calls: list[tuple[str, dict[str, Any]]] = [] def fake_patch(path: str, body: dict[str, Any]) -> dict[str, Any]: assert path == "/tasks/task-1" assert body["status"] == "done" return { "id": "task-1", "workstream_id": "ws-1", "title": "Finish MCP reliability", "status": "done", } def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: post_calls.append((path, body)) return {"id": "event-1", **body} monkeypatch.setattr(server, "_patch", fake_patch) monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "update_task_status", {"task_id": "task-1", "status": "done"}, ) assert body == { "id": "task-1", "workstream_id": "ws-1", "title": "Finish MCP reliability", "status": "done", } assert [path for path, _ in post_calls] == ["/progress"] assert post_calls[0][1]["task_id"] == "task-1" assert post_calls[0][1]["event_type"] == "task_status_changed" async def test_add_progress_event_accepts_json_string_detail(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) return {"id": "event-1", **body} monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "add_progress_event", { "summary": "MCP progress", "event_type": "note", "workstream_id": "ws-1", "detail": '{"files_changed": 3}', }, ) assert body["id"] == "event-1" assert body["detail"] == {"files_changed": 3} assert calls == [ ( "/progress", { "topic_id": None, "workstream_id": "ws-1", "task_id": None, "event_type": "note", "summary": "MCP progress", "author": "custodian", "detail": {"files_changed": 3}, }, ) ] async def test_bulk_update_task_statuses_returns_rest_shape(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) assert path == "/tasks/bulk-status-sync" return { "updated": [ {"id": "task-1", "title": "First", "status": "done"}, {"id": "task-2", "title": "Second", "status": "wait"}, ], "progress_event_ids": ["event-1", "event-2"], } monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "bulk_update_task_statuses", { "author": "codex", "session_id": "session-1", "updates": [ {"task_id": "task-1", "status": "done"}, {"task_id": "task-2", "status": "wait", "blocking_reason": "needs input"}, ], }, ) assert body["progress_event_ids"] == ["event-1", "event-2"] assert [task["status"] for task in body["updated"]] == ["done", "wait"] assert calls == [ ( "/tasks/bulk-status-sync", { "updates": [ {"task_id": "task-1", "status": "done"}, {"task_id": "task-2", "status": "wait", "blocking_reason": "needs input"}, ], "author": "codex", "session_id": "session-1", }, ) ] async def test_record_decision_returns_rest_shape_and_emits_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) if path == "/decisions": return { "id": "decision-1", "title": body["title"], "decision_type": body["decision_type"], "topic_id": body["topic_id"], "workstream_id": body["workstream_id"], "status": "open", "escalation_note": None, } if path == "/progress": return {"id": "event-1", **body} raise AssertionError(f"unexpected POST {path}") monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "record_decision", { "title": "Keep MCP stateless", "decision_type": "made", "topic_id": "topic-1", "workstream_id": "ws-1", }, ) assert body["id"] == "decision-1" assert body["title"] == "Keep MCP stateless" assert [path for path, _ in calls] == ["/decisions", "/progress"] assert calls[1][1]["decision_id"] == "decision-1" assert calls[1][1]["event_type"] == "decision_recorded" async def test_create_workstream_api_error_skips_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) return {"error": "API 422: invalid topic"} monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "create_workstream", {"topic_id": "bad-topic", "title": "No progress on failure"}, ) assert body["tool"] == "create_workstream" assert body["error"] == "API 422: invalid topic" assert [path for path, _ in calls] == ["/workstreams"] async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) return {"title": body["title"], "status": "open"} monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "record_decision", {"title": "Malformed API response", "topic_id": "topic-1"}, ) assert body["tool"] == "record_decision" assert body["error"] == "API response missing required field(s): id" assert [path for path, _ in calls] == ["/decisions"]