Coverage for mcp/tools/tasks.py: 96%
22 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
1"""Read-only MCP tools for inspecting long-running task status.
3Every long-running tool (``deploy_all``, ``destroy_all``, etc.) writes
4a JSON status file and a raw log file under ``~/.gco/tasks/`` via
5``_task_status.TaskStatusWriter``. The two tools in this module read
6those artifacts so any MCP client — including agents that don't render
7``ctx.info`` notifications — can see real-time progress without
8blocking on the original tool call's response.
10Both tools are read-only and intentionally always enabled (no feature
11flag): they never mutate AWS state and never spawn subprocesses. The
12worst case is reporting nothing when the status directory is empty.
13"""
15from __future__ import annotations
17import json
19from audit import audit_logged
20from server import mcp
22from tools._task_status import get_task, list_tasks, tail_log
25@mcp.tool(tags={"safe", "observability"})
26@audit_logged
27def task_status(task_id: str | None = None, limit: int = 20) -> str:
28 """Return live status of long-running tools.
30 Each long-running tool (deploy_all, destroy_all, bootstrap_cdk,
31 deploy_stack, destroy_stack, images_build, images_push) records
32 progress to ``~/.gco/tasks/{task_id}.json`` on every output line.
33 This tool reads that disk-backed channel — independent of whatever
34 the calling MCP client decides to do with progress notifications —
35 so operators (and other agents) can observe what's happening.
37 PIDs are re-checked on every read; a status file claiming
38 ``state=running`` whose recorded PID is no longer alive is rewritten
39 to ``state=orphaned`` in the response so callers see honest data
40 even when the original MCP wrapper exited unexpectedly while the
41 underlying CLI was still running.
43 Args:
44 task_id: Specific task to inspect (e.g. ``deploy_all-1747683123``).
45 Omit to list every known task, newest first.
46 limit: When listing, maximum number of records to return.
47 Ignored when ``task_id`` is set.
49 Returns:
50 JSON string. When ``task_id`` is set, an object with the task
51 record. When omitted, ``{"tasks": [...]}`` newest-first.
52 """
53 if task_id:
54 record = get_task(task_id)
55 if record is None:
56 return json.dumps({"error": "task_not_found", "task_id": task_id})
57 return json.dumps(record, indent=2, sort_keys=True)
58 records = list_tasks()
59 if limit > 0: 59 ↛ 61line 59 didn't jump to line 61 because the condition on line 59 was always true
60 records = records[:limit]
61 return json.dumps({"tasks": records}, indent=2, sort_keys=True)
64@mcp.tool(tags={"safe", "observability"})
65@audit_logged
66def task_tail(task_id: str, lines: int = 100) -> str:
67 """Return the last N lines of a long-running task's raw output log.
69 The log captures the full interleaved stdout+stderr of the
70 underlying subprocess (CDK deploy, finch image push, etc.) as the
71 tool wrote it to disk. Each line is prefixed with ``[stdout]`` or
72 ``[stderr]`` so observers can tell which stream produced it.
74 Args:
75 task_id: Task to read (from ``task_status``).
76 lines: Maximum lines to return. ``100`` is enough to see the
77 most recent stack milestone plus surrounding context;
78 ``500`` typically covers a full single-stack deploy.
80 Returns:
81 JSON string ``{"task_id": ..., "lines": [...]}`` containing the
82 tail. Empty list when the task hasn't emitted any output yet
83 or its log file has been pruned.
84 """
85 tail = tail_log(task_id, lines=lines)
86 return json.dumps({"task_id": task_id, "lines": tail}, indent=2)