Coverage for gco_mcp / resources / tasks.py: 100.00%
43 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Task status resources (tasks://gco/...) for the GCO MCP server.
3Reads through the MCP tasks extension (SEP-2663, the ``fastmcp_tasks``
4package in FastMCP 4) to surface the status of a long-running tool
5invocation as JSON. The extension's own ``tasks/get`` handler is reused so
6this resource reports exactly what a protocol-native ``tasks/get`` request
7would return — status, timestamps, poll interval, and the inlined result or
8error for finished tasks. Returns a graceful error stub when the FastMCP
9build in use doesn't ship the tasks extension.
10"""
12from __future__ import annotations
14import json
15import re
16from typing import Any
18# Task IDs are client-controlled strings (FastMCP forwards whatever the
19# client passed). Restrict to a generous alphanumeric+ punctuation set
20# so a malformed URI expansion can't sneak shell metacharacters into
21# downstream lookups.
22_TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
25async def _lookup_task_state(task_id: str) -> dict[str, Any] | None:
26 """Look up a task's current state through the tasks extension.
28 Delegates to ``fastmcp_tasks.handlers.tasks_get`` — the same handler
29 that serves protocol ``tasks/get`` requests — so the resource view and
30 the wire view can never disagree. Returns ``None`` when the extension
31 is unavailable on this build (the caller turns that into a graceful
32 "not available" JSON) and raises ``LookupError`` when the extension is
33 present but knows nothing about ``task_id``.
34 """
35 try:
36 from fastmcp_tasks.handlers import tasks_get
37 from server import mcp as _mcp
38 except ImportError:
39 return None
41 try:
42 record = await tasks_get(_mcp, task_id)
43 except Exception as exc:
44 # The handler raises the protocol not-found error for unknown or
45 # expired task ids (and for a docket that has not started yet).
46 raise LookupError(str(exc)) from exc
47 return _coerce_to_dict(record)
50def _coerce_to_dict(record: object) -> dict[str, Any]:
51 """Best-effort conversion of an opaque task record to a JSON-friendly dict."""
52 if isinstance(record, dict):
53 return record
54 for attr in ("model_dump", "dict", "to_dict", "_asdict"):
55 method = getattr(record, attr, None)
56 if callable(method):
57 try:
58 payload = method()
59 except Exception: # noqa: BLE001
60 continue
61 if isinstance(payload, dict):
62 return payload
63 if hasattr(record, "__dict__"):
64 return {k: v for k, v in vars(record).items() if not k.startswith("_")}
65 return {"value": str(record)}
68async def _task_resource(task_id: str) -> str:
69 """Return the current status of ``task_id`` as JSON."""
70 if not _TASK_ID_RE.match(task_id):
71 return json.dumps({"error": "invalid task_id", "value": task_id})
72 try:
73 state = await _lookup_task_state(task_id)
74 except LookupError as exc:
75 return json.dumps(
76 {
77 "error": "task not found",
78 "detail": str(exc)[:200],
79 "task_id": task_id,
80 }
81 )
82 if state is None:
83 return json.dumps(
84 {
85 "error": "task protocol not available",
86 "detail": (
87 "this build of FastMCP does not ship the tasks extension "
88 "(fastmcp_tasks) this resource handler reads through"
89 ),
90 "task_id": task_id,
91 }
92 )
93 return json.dumps({"task_id": task_id, "state": state}, indent=2, default=str)
96def register(mcp_instance: Any) -> None:
97 """Register the task-status resource against the shared MCP server."""
98 mcp_instance.resource("tasks://gco/{task_id}")(_task_resource)