fix: harden MCP write tool errors

This commit is contained in:
2026-06-07 19:30:58 +02:00
parent 8191a3e85d
commit 2cad5da0ab
4 changed files with 424 additions and 21 deletions

View File

@@ -92,6 +92,54 @@ def _delete(path: str) -> None:
return {"error": f"Request failed: {e}"}
def _mcp_error(tool_name: str, message: str, response: Any | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"error": message, "tool": tool_name}
if response is not None:
payload["response"] = response
return payload
def _response_error(
tool_name: str,
response: Any,
required_fields: tuple[str, ...] = (),
) -> dict[str, Any] | None:
"""Return an MCP-visible error payload for failed or malformed API results."""
if isinstance(response, dict) and isinstance(response.get("error"), str):
return _mcp_error(tool_name, response["error"], response)
if not isinstance(response, dict):
return _mcp_error(tool_name, "API returned a non-object response", response)
missing = [field for field in required_fields if response.get(field) is None]
if missing:
return _mcp_error(
tool_name,
f"API response missing required field(s): {', '.join(missing)}",
response,
)
return None
def _emit_progress_event(
tool_name: str,
write_result: dict[str, Any],
body: dict[str, Any],
) -> dict[str, Any] | None:
progress = _post("/progress", body)
error = _response_error(f"{tool_name}.progress_event", progress, ("id",))
if error:
return _mcp_error(
tool_name,
"Primary write succeeded, but automatic progress_event failed",
{"write_result": write_result, "progress_error": error},
)
return None
def _json_result(result: Any) -> str:
return json.dumps(result, indent=2)
# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------
@@ -449,7 +497,10 @@ def create_workstream(
"planning_priority": planning_priority,
"planning_order": planning_order,
})
_post("/progress", {
if error := _response_error("create_workstream", ws, ("id",)):
return _json_result(error)
progress_error = _emit_progress_event("create_workstream", ws, {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
@@ -457,7 +508,9 @@ def create_workstream(
"author": "custodian",
"detail": {"owner": owner, "slug": slug},
})
return json.dumps(ws, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(ws)
@mcp.tool()
@@ -487,7 +540,10 @@ def create_task(
"assignee": assignee,
"due_date": due_date,
})
_post("/progress", {
if error := _response_error("create_task", task, ("id",)):
return _json_result(error)
progress_error = _emit_progress_event("create_task", task, {
"workstream_id": workstream_id,
"task_id": task["id"],
"event_type": "task_created",
@@ -495,7 +551,9 @@ def create_task(
"author": "custodian",
"detail": {"priority": priority, "assignee": assignee},
})
return json.dumps(task, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(task)
@mcp.tool()
@@ -558,7 +616,10 @@ def update_task_status(
body["token_note"] = note
task = _patch(f"/tasks/{task_id}", body)
_post("/progress", {
if error := _response_error("update_task_status", task, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event("update_task_status", task, {
"task_id": task_id,
"workstream_id": task.get("workstream_id"),
"event_type": "task_status_changed",
@@ -566,8 +627,10 @@ def update_task_status(
"author": "custodian",
"detail": {"blocking_reason": blocking_reason},
})
if progress_error:
return _json_result(progress_error)
return json.dumps(task, indent=2)
return _json_result(task)
@mcp.tool()
@@ -585,7 +648,10 @@ def flag_for_human(task_id: str, note: str) -> str:
"needs_human": True,
"intervention_note": note,
})
_post("/progress", {
if error := _response_error("flag_for_human", task, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event("flag_for_human", task, {
"task_id": task_id,
"workstream_id": task.get("workstream_id"),
"event_type": "task_flagged_human",
@@ -593,7 +659,9 @@ def flag_for_human(task_id: str, note: str) -> str:
"author": "custodian",
"detail": {"intervention_note": note},
})
return json.dumps(task, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(task)
@mcp.tool()
@@ -609,14 +677,19 @@ def clear_human_flag(task_id: str) -> str:
task = _patch(f"/tasks/{task_id}", {
"needs_human": False,
})
_post("/progress", {
if error := _response_error("clear_human_flag", task, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event("clear_human_flag", task, {
"task_id": task_id,
"workstream_id": task.get("workstream_id"),
"event_type": "task_flag_cleared",
"summary": f"Human-intervention flag cleared: {task['title']}",
"author": "custodian",
})
return json.dumps(task, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(task)
@mcp.tool()
@@ -671,7 +744,10 @@ def record_decision(
"decided_by": decided_by,
"deadline": deadline,
})
_post("/progress", {
if error := _response_error("record_decision", decision, ("id",)):
return _json_result(error)
progress_error = _emit_progress_event("record_decision", decision, {
"topic_id": topic_id,
"workstream_id": workstream_id,
"decision_id": decision["id"],
@@ -680,7 +756,9 @@ def record_decision(
"author": "custodian",
"detail": {"status": decision.get("status"), "escalation_note": decision.get("escalation_note")},
})
return json.dumps(decision, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(decision)
@mcp.tool()
@@ -703,7 +781,10 @@ def resolve_decision(
"decided_by": decided_by,
"decided_at": datetime.now(tz=timezone.utc).isoformat(),
})
_post("/progress", {
if error := _response_error("resolve_decision", decision, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event("resolve_decision", decision, {
"topic_id": decision.get("topic_id"),
"workstream_id": decision.get("workstream_id"),
"decision_id": decision_id,
@@ -712,7 +793,9 @@ def resolve_decision(
"author": "custodian",
"detail": {"rationale": rationale},
})
return json.dumps(decision, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(decision)
@mcp.tool()
@@ -748,7 +831,9 @@ def add_progress_event(
"author": "custodian",
"detail": detail,
})
return json.dumps(event, indent=2)
if error := _response_error("add_progress_event", event, ("id",)):
return _json_result(error)
return _json_result(event)
@mcp.tool()
@@ -760,14 +845,19 @@ def update_workstream_status(workstream_id: str, status: str) -> str:
status: proposed | ready | active | blocked | backlog | finished | archived
"""
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
_post("/progress", {
if error := _response_error("update_workstream_status", ws, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event("update_workstream_status", ws, {
"workstream_id": workstream_id,
"topic_id": ws.get("topic_id"),
"event_type": "workstream_status_changed",
"summary": f"Workstream status → {status}: {ws['title']}",
"author": "custodian",
})
return json.dumps(ws, indent=2)
if progress_error:
return _json_result(progress_error)
return _json_result(ws)
@mcp.tool()