foundry-mcp 0.8.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of foundry-mcp might be problematic. Click here for more details.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +146 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,640 @@
1
+ """Unified lifecycle tool backed by ActionRouter and lifecycle helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ from mcp.server.fastmcp import FastMCP
12
+
13
+ from foundry_mcp.config import ServerConfig
14
+ from foundry_mcp.core.context import generate_correlation_id, get_correlation_id
15
+ from foundry_mcp.core.naming import canonical_tool
16
+ from foundry_mcp.core.observability import audit_log, get_metrics, mcp_tool
17
+ from foundry_mcp.core.responses import (
18
+ ErrorCode,
19
+ ErrorType,
20
+ error_response,
21
+ sanitize_error_message,
22
+ success_response,
23
+ )
24
+ from foundry_mcp.core.spec import find_specs_directory
25
+ from foundry_mcp.core.lifecycle import (
26
+ VALID_FOLDERS,
27
+ MoveResult,
28
+ LifecycleState,
29
+ archive_spec,
30
+ activate_spec,
31
+ complete_spec,
32
+ get_lifecycle_state,
33
+ move_spec,
34
+ )
35
+ from foundry_mcp.tools.unified.router import (
36
+ ActionDefinition,
37
+ ActionRouter,
38
+ ActionRouterError,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+ _metrics = get_metrics()
43
+
44
+ _ACTION_SUMMARY = {
45
+ "move": "Move a specification between pending/active/completed/archived folders",
46
+ "activate": "Activate a pending specification",
47
+ "complete": "Complete a specification with optional force overrides",
48
+ "archive": "Archive a specification for long-term storage",
49
+ "state": "Inspect the current lifecycle state and progress",
50
+ }
51
+
52
+
53
+ def _metric_name(action: str) -> str:
54
+ return f"lifecycle.{action.replace('-', '_')}"
55
+
56
+
57
+ def _request_id() -> str:
58
+ return get_correlation_id() or generate_correlation_id(prefix="lifecycle")
59
+
60
+
61
+ def _missing_specs_dir_response(request_id: str) -> dict:
62
+ return asdict(
63
+ error_response(
64
+ "No specs directory found. Use --specs-dir or set SDD_SPECS_DIR.",
65
+ error_code=ErrorCode.NOT_FOUND,
66
+ error_type=ErrorType.NOT_FOUND,
67
+ remediation="Pass a workspace path via the 'path' parameter or configure SDD_SPECS_DIR",
68
+ request_id=request_id,
69
+ )
70
+ )
71
+
72
+
73
+ def _validation_error(
74
+ *,
75
+ action: str,
76
+ field: str,
77
+ message: str,
78
+ request_id: str,
79
+ remediation: Optional[str] = None,
80
+ code: ErrorCode = ErrorCode.VALIDATION_ERROR,
81
+ ) -> dict:
82
+ return asdict(
83
+ error_response(
84
+ f"Invalid field '{field}' for lifecycle.{action}: {message}",
85
+ error_code=code,
86
+ error_type=ErrorType.VALIDATION,
87
+ remediation=remediation,
88
+ details={"field": field, "action": f"lifecycle.{action}"},
89
+ request_id=request_id,
90
+ )
91
+ )
92
+
93
+
94
+ def _resolve_specs_dir(config: ServerConfig, path: Optional[str]) -> Optional[Path]:
95
+ try:
96
+ if path:
97
+ return find_specs_directory(path)
98
+ return config.specs_dir or find_specs_directory()
99
+ except Exception: # pragma: no cover - defensive resolution guard
100
+ logger.exception("Failed to resolve specs directory", extra={"path": path})
101
+ return None
102
+
103
+
104
+ def _classify_error(error_message: str) -> tuple[ErrorCode, ErrorType, str]:
105
+ lowered = error_message.lower()
106
+ if "not found" in lowered:
107
+ return (
108
+ ErrorCode.SPEC_NOT_FOUND,
109
+ ErrorType.NOT_FOUND,
110
+ 'Verify the spec ID via spec(action="list")',
111
+ )
112
+ if "invalid folder" in lowered:
113
+ return (
114
+ ErrorCode.INVALID_FORMAT,
115
+ ErrorType.VALIDATION,
116
+ "Use one of the supported lifecycle folders",
117
+ )
118
+ if (
119
+ "cannot move" in lowered
120
+ or "cannot complete" in lowered
121
+ or "already exists" in lowered
122
+ ):
123
+ return (
124
+ ErrorCode.CONFLICT,
125
+ ErrorType.CONFLICT,
126
+ "Check the current lifecycle status and allowed transitions",
127
+ )
128
+ return (
129
+ ErrorCode.INTERNAL_ERROR,
130
+ ErrorType.INTERNAL,
131
+ "Inspect server logs for additional context",
132
+ )
133
+
134
+
135
+ def _move_result_response(
136
+ *,
137
+ action: str,
138
+ result: MoveResult,
139
+ request_id: str,
140
+ elapsed_ms: float,
141
+ ) -> dict:
142
+ metric_labels = {"status": "success" if result.success else "error"}
143
+ _metrics.counter(_metric_name(action), labels=metric_labels)
144
+
145
+ if result.success:
146
+ warnings: list[str] | None = None
147
+ if result.old_path == result.new_path:
148
+ warnings = [
149
+ "Specification already resided in the requested folder; no file movement required",
150
+ ]
151
+ data = {
152
+ "spec_id": result.spec_id,
153
+ "from_folder": result.from_folder,
154
+ "to_folder": result.to_folder,
155
+ "old_path": result.old_path,
156
+ "new_path": result.new_path,
157
+ }
158
+ return asdict(
159
+ success_response(
160
+ data=data,
161
+ warnings=warnings,
162
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
163
+ request_id=request_id,
164
+ )
165
+ )
166
+
167
+ error_message = result.error or f"Failed to execute lifecycle.{action}"
168
+ error_code, error_type, remediation = _classify_error(error_message)
169
+ return asdict(
170
+ error_response(
171
+ error_message,
172
+ error_code=error_code,
173
+ error_type=error_type,
174
+ remediation=remediation,
175
+ details={
176
+ "spec_id": result.spec_id,
177
+ "from_folder": result.from_folder,
178
+ "to_folder": result.to_folder,
179
+ },
180
+ request_id=request_id,
181
+ )
182
+ )
183
+
184
+
185
+ def _state_response(
186
+ state: LifecycleState, *, request_id: str, elapsed_ms: float
187
+ ) -> dict:
188
+ return asdict(
189
+ success_response(
190
+ data={
191
+ "spec_id": state.spec_id,
192
+ "folder": state.folder,
193
+ "status": state.status,
194
+ "progress_percentage": state.progress_percentage,
195
+ "total_tasks": state.total_tasks,
196
+ "completed_tasks": state.completed_tasks,
197
+ "can_complete": state.can_complete,
198
+ "can_archive": state.can_archive,
199
+ },
200
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
201
+ request_id=request_id,
202
+ )
203
+ )
204
+
205
+
206
+ def _handle_move(
207
+ *,
208
+ config: ServerConfig,
209
+ spec_id: Optional[str] = None,
210
+ to_folder: Optional[str] = None,
211
+ path: Optional[str] = None,
212
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
213
+ ) -> dict:
214
+ action = "move"
215
+ request_id = _request_id()
216
+
217
+ if not isinstance(spec_id, str) or not spec_id.strip():
218
+ return _validation_error(
219
+ action=action,
220
+ field="spec_id",
221
+ message="Provide a non-empty spec identifier",
222
+ remediation='Call spec(action="list") to locate the correct spec_id',
223
+ request_id=request_id,
224
+ code=ErrorCode.MISSING_REQUIRED,
225
+ )
226
+
227
+ if not isinstance(to_folder, str) or not to_folder.strip():
228
+ return _validation_error(
229
+ action=action,
230
+ field="to_folder",
231
+ message="Provide the destination folder",
232
+ remediation="Use one of: pending, active, completed, archived",
233
+ request_id=request_id,
234
+ code=ErrorCode.MISSING_REQUIRED,
235
+ )
236
+
237
+ normalized_folder = to_folder.strip().lower()
238
+ if normalized_folder not in VALID_FOLDERS:
239
+ return _validation_error(
240
+ action=action,
241
+ field="to_folder",
242
+ message=f"Unsupported folder '{to_folder}'.",
243
+ remediation="Use one of: pending, active, completed, archived",
244
+ request_id=request_id,
245
+ code=ErrorCode.INVALID_FORMAT,
246
+ )
247
+
248
+ if path is not None and not isinstance(path, str):
249
+ return _validation_error(
250
+ action=action,
251
+ field="path",
252
+ message="Workspace path must be a string",
253
+ request_id=request_id,
254
+ )
255
+
256
+ specs_dir = _resolve_specs_dir(config, path)
257
+ if specs_dir is None:
258
+ return _missing_specs_dir_response(request_id)
259
+
260
+ audit_log(
261
+ "tool_invocation",
262
+ tool="lifecycle",
263
+ action=action,
264
+ spec_id=spec_id.strip(),
265
+ to_folder=normalized_folder,
266
+ )
267
+
268
+ start = time.perf_counter()
269
+ try:
270
+ result = move_spec(spec_id.strip(), normalized_folder, specs_dir)
271
+ except Exception as exc: # pragma: no cover - defensive guard
272
+ logger.exception("Unexpected error moving spec")
273
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
274
+ return asdict(
275
+ error_response(
276
+ sanitize_error_message(exc, context="lifecycle"),
277
+ error_code=ErrorCode.INTERNAL_ERROR,
278
+ error_type=ErrorType.INTERNAL,
279
+ remediation="Inspect server logs for lifecycle move failures",
280
+ request_id=request_id,
281
+ )
282
+ )
283
+
284
+ elapsed_ms = (time.perf_counter() - start) * 1000
285
+ return _move_result_response(
286
+ action=action,
287
+ result=result,
288
+ request_id=request_id,
289
+ elapsed_ms=elapsed_ms,
290
+ )
291
+
292
+
293
+ def _handle_activate(
294
+ *,
295
+ config: ServerConfig,
296
+ spec_id: Optional[str] = None,
297
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
298
+ path: Optional[str] = None,
299
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
300
+ ) -> dict:
301
+ action = "activate"
302
+ request_id = _request_id()
303
+
304
+ if not isinstance(spec_id, str) or not spec_id.strip():
305
+ return _validation_error(
306
+ action=action,
307
+ field="spec_id",
308
+ message="Provide a non-empty spec identifier",
309
+ remediation='Call spec(action="list") to locate the correct spec_id',
310
+ request_id=request_id,
311
+ code=ErrorCode.MISSING_REQUIRED,
312
+ )
313
+
314
+ if path is not None and not isinstance(path, str):
315
+ return _validation_error(
316
+ action=action,
317
+ field="path",
318
+ message="Workspace path must be a string",
319
+ request_id=request_id,
320
+ )
321
+
322
+ specs_dir = _resolve_specs_dir(config, path)
323
+ if specs_dir is None:
324
+ return _missing_specs_dir_response(request_id)
325
+
326
+ audit_log(
327
+ "tool_invocation",
328
+ tool="lifecycle",
329
+ action=action,
330
+ spec_id=spec_id.strip(),
331
+ )
332
+
333
+ start = time.perf_counter()
334
+ try:
335
+ result = activate_spec(spec_id.strip(), specs_dir)
336
+ except Exception as exc: # pragma: no cover - defensive guard
337
+ logger.exception("Unexpected error activating spec")
338
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
339
+ return asdict(
340
+ error_response(
341
+ sanitize_error_message(exc, context="lifecycle"),
342
+ error_code=ErrorCode.INTERNAL_ERROR,
343
+ error_type=ErrorType.INTERNAL,
344
+ remediation="Inspect server logs for lifecycle activation failures",
345
+ request_id=request_id,
346
+ )
347
+ )
348
+
349
+ elapsed_ms = (time.perf_counter() - start) * 1000
350
+ return _move_result_response(
351
+ action=action,
352
+ result=result,
353
+ request_id=request_id,
354
+ elapsed_ms=elapsed_ms,
355
+ )
356
+
357
+
358
+ def _handle_complete(
359
+ *,
360
+ config: ServerConfig,
361
+ spec_id: Optional[str] = None,
362
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
363
+ force: Optional[bool] = False,
364
+ path: Optional[str] = None,
365
+ ) -> dict:
366
+ action = "complete"
367
+ request_id = _request_id()
368
+
369
+ if not isinstance(spec_id, str) or not spec_id.strip():
370
+ return _validation_error(
371
+ action=action,
372
+ field="spec_id",
373
+ message="Provide a non-empty spec identifier",
374
+ remediation='Call spec(action="list") to locate the correct spec_id',
375
+ request_id=request_id,
376
+ code=ErrorCode.MISSING_REQUIRED,
377
+ )
378
+
379
+ if force is not None and not isinstance(force, bool):
380
+ return _validation_error(
381
+ action=action,
382
+ field="force",
383
+ message="Force flag must be boolean",
384
+ request_id=request_id,
385
+ )
386
+
387
+ if path is not None and not isinstance(path, str):
388
+ return _validation_error(
389
+ action=action,
390
+ field="path",
391
+ message="Workspace path must be a string",
392
+ request_id=request_id,
393
+ )
394
+
395
+ specs_dir = _resolve_specs_dir(config, path)
396
+ if specs_dir is None:
397
+ return _missing_specs_dir_response(request_id)
398
+
399
+ audit_log(
400
+ "tool_invocation",
401
+ tool="lifecycle",
402
+ action=action,
403
+ spec_id=spec_id.strip(),
404
+ force=bool(force),
405
+ )
406
+
407
+ start = time.perf_counter()
408
+ try:
409
+ result = complete_spec(spec_id.strip(), specs_dir, force=bool(force))
410
+ except Exception as exc: # pragma: no cover - defensive guard
411
+ logger.exception("Unexpected error completing spec")
412
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
413
+ return asdict(
414
+ error_response(
415
+ sanitize_error_message(exc, context="lifecycle"),
416
+ error_code=ErrorCode.INTERNAL_ERROR,
417
+ error_type=ErrorType.INTERNAL,
418
+ remediation="Inspect server logs for lifecycle completion failures",
419
+ request_id=request_id,
420
+ )
421
+ )
422
+
423
+ elapsed_ms = (time.perf_counter() - start) * 1000
424
+ return _move_result_response(
425
+ action=action,
426
+ result=result,
427
+ request_id=request_id,
428
+ elapsed_ms=elapsed_ms,
429
+ )
430
+
431
+
432
+ def _handle_archive(
433
+ *,
434
+ config: ServerConfig,
435
+ spec_id: Optional[str] = None,
436
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
437
+ path: Optional[str] = None,
438
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
439
+ ) -> dict:
440
+ action = "archive"
441
+ request_id = _request_id()
442
+
443
+ if not isinstance(spec_id, str) or not spec_id.strip():
444
+ return _validation_error(
445
+ action=action,
446
+ field="spec_id",
447
+ message="Provide a non-empty spec identifier",
448
+ remediation='Call spec(action="list") to locate the correct spec_id',
449
+ request_id=request_id,
450
+ code=ErrorCode.MISSING_REQUIRED,
451
+ )
452
+
453
+ if path is not None and not isinstance(path, str):
454
+ return _validation_error(
455
+ action=action,
456
+ field="path",
457
+ message="Workspace path must be a string",
458
+ request_id=request_id,
459
+ )
460
+
461
+ specs_dir = _resolve_specs_dir(config, path)
462
+ if specs_dir is None:
463
+ return _missing_specs_dir_response(request_id)
464
+
465
+ audit_log(
466
+ "tool_invocation",
467
+ tool="lifecycle",
468
+ action=action,
469
+ spec_id=spec_id.strip(),
470
+ )
471
+
472
+ start = time.perf_counter()
473
+ try:
474
+ result = archive_spec(spec_id.strip(), specs_dir)
475
+ except Exception as exc: # pragma: no cover - defensive guard
476
+ logger.exception("Unexpected error archiving spec")
477
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
478
+ return asdict(
479
+ error_response(
480
+ sanitize_error_message(exc, context="lifecycle"),
481
+ error_code=ErrorCode.INTERNAL_ERROR,
482
+ error_type=ErrorType.INTERNAL,
483
+ remediation="Inspect server logs for lifecycle archive failures",
484
+ request_id=request_id,
485
+ )
486
+ )
487
+
488
+ elapsed_ms = (time.perf_counter() - start) * 1000
489
+ return _move_result_response(
490
+ action=action,
491
+ result=result,
492
+ request_id=request_id,
493
+ elapsed_ms=elapsed_ms,
494
+ )
495
+
496
+
497
+ def _handle_state(
498
+ *,
499
+ config: ServerConfig,
500
+ spec_id: Optional[str] = None,
501
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
502
+ path: Optional[str] = None,
503
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
504
+ ) -> dict:
505
+ action = "state"
506
+ request_id = _request_id()
507
+
508
+ if not isinstance(spec_id, str) or not spec_id.strip():
509
+ return _validation_error(
510
+ action=action,
511
+ field="spec_id",
512
+ message="Provide a non-empty spec identifier",
513
+ remediation='Call spec(action="list") to locate the correct spec_id',
514
+ request_id=request_id,
515
+ code=ErrorCode.MISSING_REQUIRED,
516
+ )
517
+
518
+ if path is not None and not isinstance(path, str):
519
+ return _validation_error(
520
+ action=action,
521
+ field="path",
522
+ message="Workspace path must be a string",
523
+ request_id=request_id,
524
+ )
525
+
526
+ specs_dir = _resolve_specs_dir(config, path)
527
+ if specs_dir is None:
528
+ return _missing_specs_dir_response(request_id)
529
+
530
+ start = time.perf_counter()
531
+ try:
532
+ state = get_lifecycle_state(spec_id.strip(), specs_dir)
533
+ except Exception as exc: # pragma: no cover - defensive guard
534
+ logger.exception("Unexpected error fetching lifecycle state")
535
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
536
+ return asdict(
537
+ error_response(
538
+ sanitize_error_message(exc, context="lifecycle"),
539
+ error_code=ErrorCode.INTERNAL_ERROR,
540
+ error_type=ErrorType.INTERNAL,
541
+ remediation="Inspect server logs for lifecycle state failures",
542
+ request_id=request_id,
543
+ )
544
+ )
545
+
546
+ elapsed_ms = (time.perf_counter() - start) * 1000
547
+
548
+ if state is None:
549
+ _metrics.counter(_metric_name(action), labels={"status": "not_found"})
550
+ return asdict(
551
+ error_response(
552
+ f"Spec '{spec_id.strip()}' not found",
553
+ error_code=ErrorCode.SPEC_NOT_FOUND,
554
+ error_type=ErrorType.NOT_FOUND,
555
+ remediation='Verify the spec exists via spec(action="list")',
556
+ request_id=request_id,
557
+ )
558
+ )
559
+
560
+ _metrics.counter(_metric_name(action), labels={"status": "success"})
561
+ return _state_response(state, request_id=request_id, elapsed_ms=elapsed_ms)
562
+
563
+
564
+ _LIFECYCLE_ROUTER = ActionRouter(
565
+ tool_name="lifecycle",
566
+ actions=[
567
+ ActionDefinition(
568
+ name="move",
569
+ handler=_handle_move,
570
+ summary=_ACTION_SUMMARY["move"],
571
+ ),
572
+ ActionDefinition(
573
+ name="activate",
574
+ handler=_handle_activate,
575
+ summary=_ACTION_SUMMARY["activate"],
576
+ ),
577
+ ActionDefinition(
578
+ name="complete",
579
+ handler=_handle_complete,
580
+ summary=_ACTION_SUMMARY["complete"],
581
+ ),
582
+ ActionDefinition(
583
+ name="archive",
584
+ handler=_handle_archive,
585
+ summary=_ACTION_SUMMARY["archive"],
586
+ ),
587
+ ActionDefinition(
588
+ name="state",
589
+ handler=_handle_state,
590
+ summary=_ACTION_SUMMARY["state"],
591
+ ),
592
+ ],
593
+ )
594
+
595
+
596
+ def _dispatch_lifecycle_action(
597
+ *, action: str, payload: Dict[str, Any], config: ServerConfig
598
+ ) -> dict:
599
+ try:
600
+ return _LIFECYCLE_ROUTER.dispatch(action=action, config=config, **payload)
601
+ except ActionRouterError as exc:
602
+ request_id = _request_id()
603
+ allowed = ", ".join(exc.allowed_actions)
604
+ return asdict(
605
+ error_response(
606
+ f"Unsupported lifecycle action '{action}'. Allowed actions: {allowed}",
607
+ error_code=ErrorCode.VALIDATION_ERROR,
608
+ error_type=ErrorType.VALIDATION,
609
+ remediation=f"Use one of: {allowed}",
610
+ request_id=request_id,
611
+ )
612
+ )
613
+
614
+
615
+ def register_unified_lifecycle_tool(mcp: FastMCP, config: ServerConfig) -> None:
616
+ """Register the consolidated lifecycle tool."""
617
+
618
+ @canonical_tool(mcp, canonical_name="lifecycle")
619
+ @mcp_tool(tool_name="lifecycle", emit_metrics=True, audit=True)
620
+ def lifecycle(
621
+ action: str,
622
+ spec_id: Optional[str] = None,
623
+ to_folder: Optional[str] = None,
624
+ force: Optional[bool] = False,
625
+ path: Optional[str] = None,
626
+ ) -> dict:
627
+ payload = {
628
+ "spec_id": spec_id,
629
+ "to_folder": to_folder,
630
+ "force": force,
631
+ "path": path,
632
+ }
633
+ return _dispatch_lifecycle_action(action=action, payload=payload, config=config)
634
+
635
+ logger.debug("Registered unified lifecycle tool")
636
+
637
+
638
+ __all__ = [
639
+ "register_unified_lifecycle_tool",
640
+ ]