Files
state-hub/tests/test_mcp_write_tools.py

299 lines
11 KiB
Python

"""
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"]