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

95 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +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_CONFIG_MANAGEMENT, 

54 FLAG_DESTRUCTIVE_OPERATIONS, 

55 FLAG_IMAGE_PUBLISH, 

56 FLAG_INFRASTRUCTURE_DEPLOY, 

57 FLAG_INFRASTRUCTURE_DESTROY, 

58 FLAG_LOCAL_METRICS, 

59 FLAG_LOCAL_STORAGE_SYNC, 

60 FLAG_MISSION, 

61 FLAG_MODEL_UPLOAD, 

62 FLAG_SEMANTIC_PROGRESS, 

63 FLAG_SWARM, 

64) 

65 

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

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

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

69 

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

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

72 

73 

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

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

76# --------------------------------------------------------------------------- 

77 

78_TOOL_GATING_TABLE: dict[str, str] = { 

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

80 "reserve_capacity": FLAG_CAPACITY_PURCHASE, 

81 "create_reservation": FLAG_CAPACITY_PURCHASE, 

82 "cancel_reservation": FLAG_DESTRUCTIVE_OPERATIONS, 

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

84 "models_upload": FLAG_MODEL_UPLOAD, 

85 "upload_to_regional_bucket": FLAG_MODEL_UPLOAD, 

86 "delete_model": FLAG_DESTRUCTIVE_OPERATIONS, 

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

88 "images_build": FLAG_IMAGE_PUBLISH, 

89 "images_push": FLAG_IMAGE_PUBLISH, 

90 "images_mirror": FLAG_IMAGE_PUBLISH, 

91 "images_delete_tag": FLAG_DESTRUCTIVE_OPERATIONS, 

92 "images_delete_repo": FLAG_DESTRUCTIVE_OPERATIONS, 

93 "images_cleanup": FLAG_DESTRUCTIVE_OPERATIONS, 

94 "images_prune": FLAG_DESTRUCTIVE_OPERATIONS, 

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

96 "deploy_stack": FLAG_INFRASTRUCTURE_DEPLOY, 

97 "deploy_all": FLAG_INFRASTRUCTURE_DEPLOY, 

98 "bootstrap_cdk": FLAG_INFRASTRUCTURE_DEPLOY, 

99 "addons_install": FLAG_INFRASTRUCTURE_DEPLOY, 

100 "destroy_stack": FLAG_INFRASTRUCTURE_DESTROY, 

101 "destroy_all": FLAG_INFRASTRUCTURE_DESTROY, 

102 # gco_mcp/tools/stacks.py — managed deployment config 

103 "list_deployment_regions": FLAG_CONFIG_MANAGEMENT, 

104 "add_deployment_region": FLAG_CONFIG_MANAGEMENT, 

105 "remove_deployment_region": FLAG_CONFIG_MANAGEMENT, 

106 "set_deployment_region": FLAG_CONFIG_MANAGEMENT, 

107 "set_eks_endpoint_access": FLAG_CONFIG_MANAGEMENT, 

108 "set_mission_default_model": FLAG_CONFIG_MANAGEMENT, 

109 "set_capacity_advisor_default_model": FLAG_CONFIG_MANAGEMENT, 

110 "set_claude_code_default_model": FLAG_CONFIG_MANAGEMENT, 

111 "set_codex_default_model": FLAG_CONFIG_MANAGEMENT, 

112 "set_codex_reasoning_effort": FLAG_CONFIG_MANAGEMENT, 

113 # Other destructive module-level gates 

114 "delete_job": FLAG_DESTRUCTIVE_OPERATIONS, 

115 "delete_inference": FLAG_DESTRUCTIVE_OPERATIONS, 

116 "delete_template": FLAG_DESTRUCTIVE_OPERATIONS, 

117 "delete_webhook": FLAG_DESTRUCTIVE_OPERATIONS, 

118 "delete_nodepool": FLAG_DESTRUCTIVE_OPERATIONS, 

119 "analytics_user_remove": FLAG_DESTRUCTIVE_OPERATIONS, 

120 "monitoring_user_remove": FLAG_DESTRUCTIVE_OPERATIONS, 

121 "cancel_queue_job": FLAG_DESTRUCTIVE_OPERATIONS, 

122 "task_prune": FLAG_DESTRUCTIVE_OPERATIONS, 

123 # Local filesystem and model-scoring readers 

124 "metrics_from_local_file": FLAG_LOCAL_METRICS, 

125 "metrics_semantic_progress": FLAG_SEMANTIC_PROGRESS, 

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

127 "sync_storage_bucket": FLAG_LOCAL_STORAGE_SYNC, 

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

129 "mission_start": FLAG_MISSION, 

130 "mission_status": FLAG_MISSION, 

131 "mission_iterate": FLAG_MISSION, 

132 "mission_checkpoint": FLAG_MISSION, 

133 "mission_complete": FLAG_MISSION, 

134 "mission_abort": FLAG_MISSION, 

135 "mission_resume": FLAG_MISSION, 

136 "mission_history": FLAG_MISSION, 

137 "mission_list": FLAG_MISSION, 

138 "mission_memory_search": FLAG_MISSION, 

139 # gco_mcp/tools/swarm.py — swarm supervision (orchestrator-of-missions) 

140 "swarm_start": FLAG_SWARM, 

141 "swarm_iterate": FLAG_SWARM, 

142 "swarm_status": FLAG_SWARM, 

143 "swarm_abort": FLAG_SWARM, 

144 "swarm_list": FLAG_SWARM, 

145 "swarm_plan": FLAG_SWARM, 

146} 

147 

148 

149# --------------------------------------------------------------------------- 

150# Helpers 

151# --------------------------------------------------------------------------- 

152 

153 

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

155 """Construct the pinned FastMCP resource-not-found exception.""" 

156 from fastmcp.exceptions import NotFoundError 

157 

158 return NotFoundError(message) 

159 

160 

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

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

163 

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

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

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

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

168 ``null`` JSON for those cases. 

169 """ 

170 try: 

171 target = inspect.unwrap(fn) 

172 except Exception: 

173 target = fn 

174 

175 try: 

176 src_path = inspect.getsourcefile(target) 

177 except TypeError, OSError: 

178 src_path = None 

179 

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

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

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

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

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

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

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

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

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

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

190 src_lineno: int | None = None 

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

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

193 if isinstance(co_firstlineno, int): 

194 src_lineno = co_firstlineno 

195 else: 

196 try: 

197 _src_lines, src_lineno = inspect.getsourcelines(target) 

198 except TypeError, OSError: 

199 src_lineno = None 

200 

201 rel_path: str | None = None 

202 if src_path: 

203 try: 

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

205 except ValueError: 

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

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

208 rel_path = src_path 

209 

210 return rel_path, src_lineno 

211 

212 

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

214 """Snapshot every registered tool, asynchronously. 

215 

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

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

218 """ 

219 from server import mcp 

220 

221 try: 

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

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

224 return list(await mcp._list_tools()) 

225 except Exception: 

226 return [] 

227 

228 

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

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

231 from server import mcp 

232 

233 try: 

234 # ``_list_resources`` and ``_list_resource_templates`` return 

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

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

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

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

239 except Exception: 

240 resources = [] 

241 try: 

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

243 except Exception: 

244 templates = [] 

245 return resources, templates 

246 

247 

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

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

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

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

252 return { 

253 "name": tool.name, 

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

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

256 "source_path": src_path, 

257 "source_line": src_line, 

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

259 } 

260 

261 

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

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

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

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

266 return { 

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

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

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

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

271 "source_path": src_path, 

272 "source_line": src_line, 

273 } 

274 

275 

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

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

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

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

280 return { 

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

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

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

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

285 "source_path": src_path, 

286 "source_line": src_line, 

287 } 

288 

289 

290# --------------------------------------------------------------------------- 

291# Resource handler bodies 

292# --------------------------------------------------------------------------- 

293 

294 

295async def _tools_index() -> str: 

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

297 tools = await _list_tools_async() 

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

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

300 

301 

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

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

304 for tool in await _list_tools_async(): 

305 if tool.name == tool_name: 

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

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

308 

309 

310async def _resources_index() -> str: 

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

312 resources, templates = await _list_resources_async() 

313 payload = { 

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

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

316 } 

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

318 

319 

320async def _feature_flags() -> str: 

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

322 

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

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

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

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

327 every per-tool flag. 

328 """ 

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

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

331 # The table is executable registry metadata: drift must fail loudly 

332 # rather than silently omitting a gated tool from introspection. 

333 by_flag[flag].append(tool_name) 

334 

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

336 { 

337 "name": FLAG_ALL_TOOLS, 

338 "default": False, 

339 "gated_tools": [], 

340 } 

341 ] 

342 for flag in ALL_FLAGS: 

343 flags_list.append( 

344 { 

345 "name": flag, 

346 "default": False, 

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

348 } 

349 ) 

350 

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

352 

353 

354# --------------------------------------------------------------------------- 

355# Registration 

356# --------------------------------------------------------------------------- 

357 

358 

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

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

361 

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

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

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

365 """ 

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

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

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

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

370 

371 

372# Make the helpers reachable for tests without importing the 

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

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

375__all__ = [ 

376 "register", 

377] 

378 

379 

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

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

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

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

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