generated from coulomb/repo-seed
fix: harden MCP write tool errors
This commit is contained in:
254
tests/test_mcp_write_tools.py
Normal file
254
tests/test_mcp_write_tools.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
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_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"]
|
||||
Reference in New Issue
Block a user