Coverage for gco_mcp/resources/self.py: 89.08%

105 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Self-indexing resources (``mcp://gco/...``) for the GCO MCP server. 

2 

3Four templates that surface the live MCP catalog through resource URIs 

4so introspection clients (and AI assistants) can list every registered 

5tool and resource template at a glance, plus the feature-flag map that 

6gates each gated tool. Always-on — no feature flag gates these. 

7 

8* ``mcp://gco/tools/index`` — full tool index. Returns JSON shaped as 

9 ``{"tools": [{"name", "description", "tags", "source_path", 

10 "source_line", "gating_flag"}, ...]}``. ``source_path`` is project- 

11 root-relative; ``source_line`` is the 1-indexed first line of the 

12 wrapped function. ``gating_flag`` is the ``GCO_ENABLE_*`` constant 

13 that gates the tool, or ``null`` when the tool is always-on. 

14* ``mcp://gco/tools/{tool_name}`` — single-tool detail. Same shape as 

15 one element of the index. Raises :class:`fastmcp.exceptions.NotFoundError` 

16 for unknown names so the FastMCP error-handling middleware maps it to 

17 MCP error code ``-32002``. 

18* ``mcp://gco/resources/index`` — index of every static resource and 

19 resource template. Returns ``{"resources": [{"uri", "name", 

20 "description", "tags", "source_path", "source_line"}, ...], 

21 "templates": [{"uri_template", "name", "description", ...}, ...]}``. 

22* ``mcp://gco/feature-flags`` — the umbrella + per-tool flag table. 

23 Returns ``{"flags": [{"name", "default", "gated_tools": [...]}, 

24 ...]}``. The ``gated_tools`` list is the static map below, kept in 

25 sync by hand with the ``if is_enabled(...)`` blocks at the top of 

26 each ``gco_mcp/tools/*.py`` module. The ``mission`` family lives in a 

27 module-level ``if`` so its nine tools all gate together; image and 

28 destructive tools use multiple-flag combinations. 

29 

30Tool-name → flag inference uses a static ``_TOOL_GATING_TABLE`` rather 

31than re-parsing the source modules at request time. That table is 

32short, easy to keep in sync, and cheap to read; the alternative — AST- 

33walking each ``gco_mcp/tools/*.py`` module on every list call — would 

34either thrash the disk on every introspection or grow a layer of 

35caches we'd then have to invalidate. The map is exercised in 

36``tests/test_mcp_self_resources.py`` so any drift between it and the 

37real gating bodies trips a test failure rather than a silent 

38documentation lie. 

39""" 

40 

41from __future__ import annotations 

42 

43import inspect 

44import json 

45import sys 

46from pathlib import Path 

47from typing import Any, cast 

48 

49from feature_flags import ( 

50 ALL_FLAGS, 

51 FLAG_ALL_TOOLS, 

52 FLAG_CAPACITY_PURCHASE, 

53 FLAG_DESTRUCTIVE_OPERATIONS, 

54 FLAG_IMAGE_PUBLISH, 

55 FLAG_INFRASTRUCTURE_DEPLOY, 

56 FLAG_INFRASTRUCTURE_DESTROY, 

57 FLAG_LOCAL_METRICS, 

58 FLAG_LOCAL_STORAGE_SYNC, 

59 FLAG_MISSION, 

60 FLAG_MODEL_UPLOAD, 

61 FLAG_SEMANTIC_PROGRESS, 

62) 

63 

64# Import the live FastMCP instance so the resource handlers can hit 

65# the same registry the rest of the server sees. 

66sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 

67 

68# Project root used to build relative ``source_path`` strings. 

69_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent 

70 

71 

72# --------------------------------------------------------------------------- 

73# Static gating table — kept in sync with the per-module ``if`` blocks. 

74# --------------------------------------------------------------------------- 

75 

76_TOOL_GATING_TABLE: dict[str, str] = { 

77 # gco_mcp/tools/capacity.py — purchase + destructive tools 

78 "reserve_capacity": FLAG_CAPACITY_PURCHASE, 

79 "create_reservation": FLAG_CAPACITY_PURCHASE, 

80 "cancel_reservation": FLAG_DESTRUCTIVE_OPERATIONS, 

81 # gco_mcp/tools/models.py / storage.py — local model-data upload + deletion 

82 "models_upload": FLAG_MODEL_UPLOAD, 

83 "upload_to_regional_bucket": FLAG_MODEL_UPLOAD, 

84 "delete_model": FLAG_DESTRUCTIVE_OPERATIONS, 

85 # gco_mcp/tools/images.py — image-publish + destructive 

86 "images_build": FLAG_IMAGE_PUBLISH, 

87 "images_push": FLAG_IMAGE_PUBLISH, 

88 "images_mirror": FLAG_IMAGE_PUBLISH, 

89 "images_delete_tag": FLAG_DESTRUCTIVE_OPERATIONS, 

90 "images_delete_repo": FLAG_DESTRUCTIVE_OPERATIONS, 

91 "images_cleanup": FLAG_DESTRUCTIVE_OPERATIONS, 

92 "images_prune": FLAG_DESTRUCTIVE_OPERATIONS, 

93 # gco_mcp/tools/stacks.py — deploy + destroy 

94 "deploy_stack": FLAG_INFRASTRUCTURE_DEPLOY, 

95 "deploy_all": FLAG_INFRASTRUCTURE_DEPLOY, 

96 "bootstrap_cdk": FLAG_INFRASTRUCTURE_DEPLOY, 

97 "addons_install": FLAG_INFRASTRUCTURE_DEPLOY, 

98 "destroy_stack": FLAG_INFRASTRUCTURE_DESTROY, 

99 "destroy_all": FLAG_INFRASTRUCTURE_DESTROY, 

100 # Other destructive module-level gates 

101 "delete_job": FLAG_DESTRUCTIVE_OPERATIONS, 

102 "delete_inference": FLAG_DESTRUCTIVE_OPERATIONS, 

103 "delete_template": FLAG_DESTRUCTIVE_OPERATIONS, 

104 "delete_webhook": FLAG_DESTRUCTIVE_OPERATIONS, 

105 "delete_nodepool": FLAG_DESTRUCTIVE_OPERATIONS, 

106 "analytics_user_remove": FLAG_DESTRUCTIVE_OPERATIONS, 

107 "monitoring_user_remove": FLAG_DESTRUCTIVE_OPERATIONS, 

108 "cancel_queue_job": FLAG_DESTRUCTIVE_OPERATIONS, 

109 "task_prune": FLAG_DESTRUCTIVE_OPERATIONS, 

110 # Local filesystem and model-scoring readers 

111 "metrics_from_local_file": FLAG_LOCAL_METRICS, 

112 "metrics_semantic_progress": FLAG_SEMANTIC_PROGRESS, 

113 # gco_mcp/tools/storage.py — local filesystem transfer 

114 "sync_storage_bucket": FLAG_LOCAL_STORAGE_SYNC, 

115 # gco_mcp/tools/mission.py — module-level gate 

116 "mission_start": FLAG_MISSION, 

117 "mission_status": FLAG_MISSION, 

118 "mission_iterate": FLAG_MISSION, 

119 "mission_checkpoint": FLAG_MISSION, 

120 "mission_complete": FLAG_MISSION, 

121 "mission_abort": FLAG_MISSION, 

122 "mission_resume": FLAG_MISSION, 

123 "mission_history": FLAG_MISSION, 

124 "mission_list": FLAG_MISSION, 

125} 

126 

127 

128# --------------------------------------------------------------------------- 

129# Helpers 

130# --------------------------------------------------------------------------- 

131 

132 

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

134 """Construct the best available "not found" exception. 

135 

136 Prefers :class:`fastmcp.exceptions.NotFoundError` because the 

137 FastMCP error-handling middleware maps it to MCP error code 

138 ``-32002`` for resource reads. Falls back through ``ResourceError`` 

139 and finally :class:`KeyError` so the resource layer still surfaces 

140 a structured not-found regardless of the FastMCP build in use. 

141 """ 

142 try: 

143 from fastmcp.exceptions import NotFoundError 

144 

145 return NotFoundError(message) 

146 except ImportError: 

147 pass 

148 try: 

149 from fastmcp.exceptions import ResourceError 

150 

151 return ResourceError(message) 

152 except ImportError: 

153 pass 

154 return KeyError(message) 

155 

156 

157def _source_info_for_fn(fn: Any) -> tuple[str | None, int | None]: 

158 """Return (project-root-relative path, 1-indexed first line) for ``fn``. 

159 

160 Walks :func:`inspect.unwrap` so the source location of the wrapped 

161 function is reported rather than the audit decorator's wrapper. 

162 Both halves can be ``None`` when the source is unavailable (built- 

163 ins, dynamically generated functions); the index handler emits 

164 ``null`` JSON for those cases. 

165 """ 

166 try: 

167 target = inspect.unwrap(fn) 

168 except Exception: 

169 target = fn 

170 

171 try: 

172 src_path = inspect.getsourcefile(target) 

173 except TypeError, OSError: 

174 src_path = None 

175 

176 # Resolve the first line number. Prefer the code object's 

177 # ``co_firstlineno``: it is always present for real Python functions 

178 # and lambdas and equals what ``inspect.getsourcelines`` reports for 

179 # them, but (unlike ``getsourcelines``) it needs no re-read of the 

180 # on-disk source. That robustness matters under pytest's assertion- 

181 # rewriting import hook, where re-reading the source of a function 

182 # defined in a rewritten module raises ``OSError`` on newer CPython 

183 # 3.14 patch releases and would otherwise drop the line to ``None``. 

184 # Fall back to ``getsourcelines`` for the rare callable that exposes 

185 # a source location but no ``__code__``, and to ``None`` for built-ins. 

186 src_lineno: int | None = None 

187 code = getattr(target, "__code__", None) 

188 co_firstlineno = getattr(code, "co_firstlineno", None) 

189 if isinstance(co_firstlineno, int): 

190 src_lineno = co_firstlineno 

191 else: 

192 try: 

193 _src_lines, src_lineno = inspect.getsourcelines(target) 

194 except TypeError, OSError: 

195 src_lineno = None 

196 

197 rel_path: str | None = None 

198 if src_path: 

199 try: 

200 rel_path = str(Path(src_path).resolve().relative_to(_PROJECT_ROOT)) 

201 except ValueError: 

202 # Tool defined outside the project tree (e.g. site- 

203 # packages). Fall back to the absolute path. 

204 rel_path = src_path 

205 

206 return rel_path, src_lineno 

207 

208 

209async def _list_tools_async() -> list[Any]: 

210 """Snapshot every registered tool, asynchronously. 

211 

212 The catch-all keeps a transient FastMCP error from blowing up the 

213 introspection endpoint — an empty list is safer than a 500. 

214 """ 

215 from server import mcp 

216 

217 try: 

218 # ``_list_tools`` returns a ``Sequence[Tool]``; widen to 

219 # ``list[Any]`` for the JSON-projection helpers below. 

220 return list(await mcp._list_tools()) 

221 except Exception: 

222 return [] 

223 

224 

225async def _list_resources_async() -> tuple[list[Any], list[Any]]: 

226 """Snapshot static resources and resource templates, asynchronously.""" 

227 from server import mcp 

228 

229 try: 

230 # ``_list_resources`` and ``_list_resource_templates`` return 

231 # ``Sequence[Resource]`` and ``Sequence[ResourceTemplate]`` 

232 # respectively; widen to ``list[Any]`` so the JSON-projection 

233 # helpers don't have to know FastMCP's concrete classes. 

234 resources = list(await mcp._list_resources()) 

235 except Exception: 

236 resources = [] 

237 try: 

238 templates = list(await mcp._list_resource_templates()) 

239 except Exception: 

240 templates = [] 

241 return resources, templates 

242 

243 

244def _tool_to_dict(tool: Any) -> dict[str, Any]: 

245 """Build the index entry shape from a FastMCP tool object.""" 

246 src_path, src_line = _source_info_for_fn(getattr(tool, "fn", None)) 

247 tags = getattr(tool, "tags", None) or set() 

248 return { 

249 "name": tool.name, 

250 "description": getattr(tool, "description", "") or "", 

251 "tags": sorted(str(t) for t in tags), 

252 "source_path": src_path, 

253 "source_line": src_line, 

254 "gating_flag": _TOOL_GATING_TABLE.get(tool.name), 

255 } 

256 

257 

258def _resource_to_dict(resource: Any) -> dict[str, Any]: 

259 """Build the index entry shape from a FastMCP static resource.""" 

260 src_path, src_line = _source_info_for_fn(getattr(resource, "fn", None)) 

261 tags = getattr(resource, "tags", None) or set() 

262 return { 

263 "uri": str(getattr(resource, "uri", "")), 

264 "name": getattr(resource, "name", "") or "", 

265 "description": getattr(resource, "description", "") or "", 

266 "tags": sorted(str(t) for t in tags), 

267 "source_path": src_path, 

268 "source_line": src_line, 

269 } 

270 

271 

272def _template_to_dict(template: Any) -> dict[str, Any]: 

273 """Build the index entry shape from a FastMCP resource template.""" 

274 src_path, src_line = _source_info_for_fn(getattr(template, "fn", None)) 

275 tags = getattr(template, "tags", None) or set() 

276 return { 

277 "uri_template": getattr(template, "uri_template", "") or "", 

278 "name": getattr(template, "name", "") or "", 

279 "description": getattr(template, "description", "") or "", 

280 "tags": sorted(str(t) for t in tags), 

281 "source_path": src_path, 

282 "source_line": src_line, 

283 } 

284 

285 

286# --------------------------------------------------------------------------- 

287# Resource handler bodies 

288# --------------------------------------------------------------------------- 

289 

290 

291async def _tools_index() -> str: 

292 """Return the full tool index as a JSON string.""" 

293 tools = await _list_tools_async() 

294 payload = {"tools": [_tool_to_dict(t) for t in tools]} 

295 return json.dumps(payload, default=str) 

296 

297 

298async def _tool_detail(tool_name: str) -> str: 

299 """Return one tool's detail dict as JSON, or raise not-found.""" 

300 for tool in await _list_tools_async(): 

301 if tool.name == tool_name: 

302 return json.dumps(_tool_to_dict(tool), default=str) 

303 raise _make_not_found(f"tool {tool_name!r} is not registered") 

304 

305 

306async def _resources_index() -> str: 

307 """Return the full resource + template index as a JSON string.""" 

308 resources, templates = await _list_resources_async() 

309 payload = { 

310 "resources": [_resource_to_dict(r) for r in resources], 

311 "templates": [_template_to_dict(t) for t in templates], 

312 } 

313 return json.dumps(payload, default=str) 

314 

315 

316async def _feature_flags() -> str: 

317 """Return the feature-flag table as a JSON string. 

318 

319 Each entry carries the flag's name, its always-False default 

320 (gates default off until the operator opts in), and the list of 

321 tool names the flag gates. The umbrella flag ``GCO_ENABLE_ALL_TOOLS`` 

322 appears with an empty ``gated_tools`` list because it overrides 

323 every per-tool flag. 

324 """ 

325 by_flag: dict[str, list[str]] = {flag: [] for flag in ALL_FLAGS} 

326 for tool_name, flag in _TOOL_GATING_TABLE.items(): 

327 if flag in by_flag: 327 ↛ 326line 327 didn't jump to line 326 because the condition on line 327 was always true

328 by_flag[flag].append(tool_name) 

329 

330 flags_list: list[dict[str, Any]] = [ 

331 { 

332 "name": FLAG_ALL_TOOLS, 

333 "default": False, 

334 "gated_tools": [], 

335 } 

336 ] 

337 for flag in ALL_FLAGS: 

338 flags_list.append( 

339 { 

340 "name": flag, 

341 "default": False, 

342 "gated_tools": sorted(by_flag.get(flag, [])), 

343 } 

344 ) 

345 

346 return json.dumps({"flags": flags_list}, default=str) 

347 

348 

349# --------------------------------------------------------------------------- 

350# Registration 

351# --------------------------------------------------------------------------- 

352 

353 

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

355 """Register the four self-indexing resource handlers. 

356 

357 Always-on. The handlers are pure functions of the live FastMCP 

358 registry plus the static gating table above, so registering them 

359 on import has no side effects beyond exposing the URIs. 

360 """ 

361 mcp_instance.resource("mcp://gco/tools/index")(_tools_index) 

362 mcp_instance.resource("mcp://gco/tools/{tool_name}")(_tool_detail) 

363 mcp_instance.resource("mcp://gco/resources/index")(_resources_index) 

364 mcp_instance.resource("mcp://gco/feature-flags")(_feature_flags) 

365 

366 

367# Make the helpers reachable for tests without importing the 

368# private leading-underscore symbols. The handler functions stay 

369# private because they're driven through FastMCP's resource layer. 

370__all__ = [ 

371 "register", 

372] 

373 

374 

375# Auto-cast helper: keep mypy quiet about ``Any`` returns in the 

376# resource bodies (FastMCP's resource decorator types ``fn`` as 

377# ``Callable[..., str | bytes | dict | list]``). Cast at the call 

378# site rather than wrapping every helper in a string-only signature. 

379cast # noqa: B018 - re-exported only to keep ``cast`` imported