Coverage for gco_mcp / resources / mission.py: 100.00%
67 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Mission session resources (``mission://sessions/{session_id}``, ``.../report``, ``.../audit-replay``).
3[gated by GCO_ENABLE_MISSION]
5Three resource templates that surface live Mission_Session state, the
6durable Final_Report artifact, and the in-process audit trail through the
7FastMCP resource layer:
9* ``mission://sessions/{session_id}`` — returns the JSON-serialised live
10 session payload. Returns a JSON error envelope when the session is
11 unknown so that tool-only clients (which call the synthetic
12 ``read_resource`` tool produced by the Resources As Tools transform)
13 receive a stable string body rather than a transport-level error.
14* ``mission://sessions/{session_id}/report`` — returns the Final_Report
15 JSON for a terminal session. Raises a not-found error when the
16 session is missing, not yet terminal, or its report has not been
17 written; FastMCP maps that exception to the MCP ``-32002 Resource
18 not found`` code on the wire.
19* ``mission://sessions/{session_id}/audit-replay`` — reconstructs the
20 session's iteration history from the in-process audit collector
21 (installed at registration time so the buffer always exists).
23All three templates are gated by :data:`feature_flags.FLAG_MISSION` at
24registration time. When the flag is unset, :func:`register` is a no-op
25so importing this module from ``resources/__init__.py`` is always safe.
26"""
28from __future__ import annotations
30import json
31import sys
32from collections.abc import Mapping
33from pathlib import Path
34from typing import Any
36from feature_flags import FLAG_MISSION, is_enabled
38# Mission package lives under ``gco_mcp/mission/``; the path-injection
39# pattern matches the rest of the MCP module surface so ``import
40# mission.*`` resolves without making the ``mcp`` directory a package.
41sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
44def _strip_private_fields(session: Mapping[str, Any]) -> dict[str, Any]:
45 """Return a JSON-safe copy of ``session`` with private criterion keys dropped.
47 Thin alias over :func:`mission.validation.strip_private_fields` —
48 the canonical implementation lives next to ``validate_criteria``
49 (which creates the ``_parsed_ast`` keys) so a single function
50 governs the JSON-safety contract across CLI, MCP tools, and MCP
51 resources.
52 """
53 from mission.validation import strip_private_fields
55 return strip_private_fields(session)
58def _make_not_found(message: str) -> Exception:
59 """Construct the best available "not found" exception for a missing resource.
61 Prefers :class:`fastmcp.exceptions.NotFoundError` because the
62 FastMCP error-handling middleware maps it (along with
63 :class:`KeyError` and :class:`FileNotFoundError`) to MCP error code
64 ``-32002`` when the method namespace is ``resources/``. Falls back
65 to :class:`fastmcp.exceptions.ResourceError` and then to
66 :class:`KeyError` so the resource layer still surfaces a structured
67 not-found regardless of the exact FastMCP build in use.
68 """
69 try:
70 from fastmcp.exceptions import NotFoundError
72 return NotFoundError(message)
73 except ImportError:
74 pass
75 try:
76 from fastmcp.exceptions import ResourceError
78 return ResourceError(message)
79 except ImportError:
80 pass
81 return KeyError(message)
84def _session_resource(session_id: str) -> str:
85 """Return the live JSON for the Mission session ``session_id``.
87 Returns a JSON error envelope (``{"error": "session_not_found",
88 "session_id": ...}``) when no record exists rather than raising,
89 because the synthetic ``read_resource`` tool from the Resources As
90 Tools transform expects a string body.
91 """
92 from mission.state import get_backend
94 backend = get_backend()
95 session = backend.load_session(session_id)
96 if session is None:
97 return json.dumps({"error": "session_not_found", "session_id": session_id})
98 return json.dumps(_strip_private_fields(session), default=str)
101def _session_report_resource(session_id: str) -> str:
102 """Return the persisted Final_Report JSON for a terminal session.
104 Raises a not-found error (mapped by the FastMCP error-handling
105 middleware to MCP code ``-32002``) when the session is missing,
106 when it is not yet in a terminal state, or when the matching
107 report payload has not been written. The :class:`FilesystemBackend`
108 stores reports as sibling ``<session_id>.report.json`` files; other
109 backends embed the report under ``session["final_report"]``.
110 """
111 from mission.state import FilesystemBackend, get_backend
112 from mission.types import TERMINAL_STATES
114 backend = get_backend()
115 session = backend.load_session(session_id)
116 if session is None:
117 raise _make_not_found(f"Mission session {session_id!r} not found")
119 status = session.get("status")
120 if status not in TERMINAL_STATES:
121 raise _make_not_found(f"Mission session {session_id!r} is not terminal (status={status!r})")
123 # ``FilesystemBackend`` persists the report next to the session
124 # JSON. Read it back verbatim so the on-disk artifact is the
125 # authoritative payload.
126 if isinstance(backend, FilesystemBackend):
127 report_path = backend.root / f"{session_id}.report.json"
128 try:
129 return report_path.read_text(encoding="utf-8")
130 except FileNotFoundError as err:
131 raise _make_not_found(
132 f"Mission session {session_id!r} terminal but report not found"
133 ) from err
135 # Other backends (today, the DynamoDB stub) embed the report on
136 # the session itself under ``final_report``.
137 report = session.get("final_report")
138 if report is None:
139 raise _make_not_found(f"Mission session {session_id!r} terminal but report not found")
140 return json.dumps(report, default=str)
143def _session_audit_replay_resource(session_id: str) -> str:
144 """Return the iteration history reconstructed from in-process audit entries.
146 Reads from the :class:`mission.audit.MissionAuditCollectorHandler`
147 ring buffer attached at server start, projects through
148 :func:`mission.audit.replay_audit_entries`, and returns
149 ``{"session_id": session_id, "iterations": [...], "note":
150 "Reconstructed from in-process audit handler"}`` as JSON.
152 Returns ``iterations: []`` when the session is unknown to the
153 handler — does NOT 404. The motivation: a Mission session that
154 finished before the resource was first read still has a valid
155 entry in the persistent session backend, but its audit entries
156 may have aged out of the bounded ring buffer. An empty
157 reconstruction is the honest answer; a 404 would imply the
158 session never existed.
159 """
160 from mission.audit import get_collector, replay_audit_entries
162 collector = get_collector()
163 note = "Reconstructed from in-process audit handler"
164 if collector is None:
165 # No handler attached (e.g. a CLI process that imported the
166 # resources package without the install hook firing). Empty
167 # reconstruction.
168 return json.dumps(
169 {"session_id": session_id, "iterations": [], "note": note},
170 default=str,
171 )
173 entries = collector.entries_for(session_id)
174 iterations = replay_audit_entries(session_id, entries)
175 return json.dumps(
176 {"session_id": session_id, "iterations": iterations, "note": note},
177 default=str,
178 )
181def register(mcp_instance: Any) -> None:
182 """Register Mission resource templates against the shared MCP server.
184 Gated by :data:`feature_flags.FLAG_MISSION`: when the flag is unset
185 this function is a no-op so importing this module from
186 :mod:`resources` stays side-effect-free. With the flag set, three
187 templates are registered and become reachable via the synthetic
188 ``read_resource`` tool from the Resources As Tools transform.
189 """
190 if not is_enabled(FLAG_MISSION):
191 return
192 # Install the in-process collector when the resource module is
193 # registered so the audit-replay path always has a buffer to
194 # read from. Idempotent — call-safe across reloads.
195 from mission.audit import install_collector
197 install_collector()
199 mcp_instance.resource("mission://sessions/{session_id}")(_session_resource)
200 mcp_instance.resource("mission://sessions/{session_id}/report")(_session_report_resource)
201 mcp_instance.resource("mission://sessions/{session_id}/audit-replay")(
202 _session_audit_replay_resource
203 )