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

1"""Mission session resources (``mission://sessions/{session_id}`` and ``.../report``). 

2 

3[gated by GCO_ENABLE_MISSION] 

4 

5Two resource templates that surface live Mission_Session state and the 

6durable Final_Report artifact through the FastMCP resource layer: 

7 

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. 

18 

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

23 

24from __future__ import annotations 

25 

26import json 

27import sys 

28from collections.abc import Mapping 

29from pathlib import Path 

30from typing import Any 

31 

32from feature_flags import FLAG_MISSION, is_enabled 

33 

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

38 

39 

40def _strip_private_fields(session: Mapping[str, Any]) -> dict[str, Any]: 

41 """Return a JSON-safe copy of ``session`` with private criterion keys dropped. 

42 

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 

50 

51 return strip_private_fields(session) 

52 

53 

54def _make_not_found(message: str) -> Exception: 

55 """Construct the best available "not found" exception for a missing resource. 

56 

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 

67 

68 return NotFoundError(message) 

69 except ImportError: 

70 pass 

71 try: 

72 from fastmcp.exceptions import ResourceError 

73 

74 return ResourceError(message) 

75 except ImportError: 

76 pass 

77 return KeyError(message) 

78 

79 

80def _session_resource(session_id: str) -> str: 

81 """Return the live JSON for the Mission session ``session_id``. 

82 

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 

89 

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) 

95 

96 

97def _session_report_resource(session_id: str) -> str: 

98 """Return the persisted Final_Report JSON for a terminal session. 

99 

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 

109 

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

114 

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

118 

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 

130 

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) 

137 

138 

139def _session_audit_replay_resource(session_id: str) -> str: 

140 """Return the iteration history reconstructed from in-process audit entries. 

141 

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. 

147 

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 

157 

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 ) 

168 

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 ) 

175 

176 

177def register(mcp_instance: Any) -> None: 

178 """Register Mission resource templates against the shared MCP server. 

179 

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 

192 

193 install_collector() 

194 

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 )