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

1"""Read-only MCP tools for inspecting long-running task status. 

2 

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. 

9 

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

14 

15from __future__ import annotations 

16 

17import json 

18 

19from audit import audit_logged 

20from server import mcp 

21 

22from tools._task_status import get_task, list_tasks, tail_log 

23 

24 

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. 

29 

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. 

36 

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. 

42 

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. 

48 

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) 

62 

63 

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. 

68 

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. 

73 

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. 

79 

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)