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