foundry-mcp 0.3.3__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.
Files changed (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -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 +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -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 +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -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/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -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 +123 -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 +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -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 +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,632 @@
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
+ ) -> dict:
213
+ action = "move"
214
+ request_id = _request_id()
215
+
216
+ if not isinstance(spec_id, str) or not spec_id.strip():
217
+ return _validation_error(
218
+ action=action,
219
+ field="spec_id",
220
+ message="Provide a non-empty spec identifier",
221
+ remediation='Call spec(action="list") to locate the correct spec_id',
222
+ request_id=request_id,
223
+ code=ErrorCode.MISSING_REQUIRED,
224
+ )
225
+
226
+ if not isinstance(to_folder, str) or not to_folder.strip():
227
+ return _validation_error(
228
+ action=action,
229
+ field="to_folder",
230
+ message="Provide the destination folder",
231
+ remediation="Use one of: pending, active, completed, archived",
232
+ request_id=request_id,
233
+ code=ErrorCode.MISSING_REQUIRED,
234
+ )
235
+
236
+ normalized_folder = to_folder.strip().lower()
237
+ if normalized_folder not in VALID_FOLDERS:
238
+ return _validation_error(
239
+ action=action,
240
+ field="to_folder",
241
+ message=f"Unsupported folder '{to_folder}'.",
242
+ remediation="Use one of: pending, active, completed, archived",
243
+ request_id=request_id,
244
+ code=ErrorCode.INVALID_FORMAT,
245
+ )
246
+
247
+ if path is not None and not isinstance(path, str):
248
+ return _validation_error(
249
+ action=action,
250
+ field="path",
251
+ message="Workspace path must be a string",
252
+ request_id=request_id,
253
+ )
254
+
255
+ specs_dir = _resolve_specs_dir(config, path)
256
+ if specs_dir is None:
257
+ return _missing_specs_dir_response(request_id)
258
+
259
+ audit_log(
260
+ "tool_invocation",
261
+ tool="lifecycle",
262
+ action=action,
263
+ spec_id=spec_id.strip(),
264
+ to_folder=normalized_folder,
265
+ )
266
+
267
+ start = time.perf_counter()
268
+ try:
269
+ result = move_spec(spec_id.strip(), normalized_folder, specs_dir)
270
+ except Exception as exc: # pragma: no cover - defensive guard
271
+ logger.exception("Unexpected error moving spec")
272
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
273
+ return asdict(
274
+ error_response(
275
+ sanitize_error_message(exc, context="lifecycle"),
276
+ error_code=ErrorCode.INTERNAL_ERROR,
277
+ error_type=ErrorType.INTERNAL,
278
+ remediation="Inspect server logs for lifecycle move failures",
279
+ request_id=request_id,
280
+ )
281
+ )
282
+
283
+ elapsed_ms = (time.perf_counter() - start) * 1000
284
+ return _move_result_response(
285
+ action=action,
286
+ result=result,
287
+ request_id=request_id,
288
+ elapsed_ms=elapsed_ms,
289
+ )
290
+
291
+
292
+ def _handle_activate(
293
+ *,
294
+ config: ServerConfig,
295
+ spec_id: Optional[str] = None,
296
+ path: Optional[str] = None,
297
+ ) -> dict:
298
+ action = "activate"
299
+ request_id = _request_id()
300
+
301
+ if not isinstance(spec_id, str) or not spec_id.strip():
302
+ return _validation_error(
303
+ action=action,
304
+ field="spec_id",
305
+ message="Provide a non-empty spec identifier",
306
+ remediation='Call spec(action="list") to locate the correct spec_id',
307
+ request_id=request_id,
308
+ code=ErrorCode.MISSING_REQUIRED,
309
+ )
310
+
311
+ if path is not None and not isinstance(path, str):
312
+ return _validation_error(
313
+ action=action,
314
+ field="path",
315
+ message="Workspace path must be a string",
316
+ request_id=request_id,
317
+ )
318
+
319
+ specs_dir = _resolve_specs_dir(config, path)
320
+ if specs_dir is None:
321
+ return _missing_specs_dir_response(request_id)
322
+
323
+ audit_log(
324
+ "tool_invocation",
325
+ tool="lifecycle",
326
+ action=action,
327
+ spec_id=spec_id.strip(),
328
+ )
329
+
330
+ start = time.perf_counter()
331
+ try:
332
+ result = activate_spec(spec_id.strip(), specs_dir)
333
+ except Exception as exc: # pragma: no cover - defensive guard
334
+ logger.exception("Unexpected error activating spec")
335
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
336
+ return asdict(
337
+ error_response(
338
+ sanitize_error_message(exc, context="lifecycle"),
339
+ error_code=ErrorCode.INTERNAL_ERROR,
340
+ error_type=ErrorType.INTERNAL,
341
+ remediation="Inspect server logs for lifecycle activation failures",
342
+ request_id=request_id,
343
+ )
344
+ )
345
+
346
+ elapsed_ms = (time.perf_counter() - start) * 1000
347
+ return _move_result_response(
348
+ action=action,
349
+ result=result,
350
+ request_id=request_id,
351
+ elapsed_ms=elapsed_ms,
352
+ )
353
+
354
+
355
+ def _handle_complete(
356
+ *,
357
+ config: ServerConfig,
358
+ spec_id: Optional[str] = None,
359
+ force: Optional[bool] = False,
360
+ path: Optional[str] = None,
361
+ ) -> dict:
362
+ action = "complete"
363
+ request_id = _request_id()
364
+
365
+ if not isinstance(spec_id, str) or not spec_id.strip():
366
+ return _validation_error(
367
+ action=action,
368
+ field="spec_id",
369
+ message="Provide a non-empty spec identifier",
370
+ remediation='Call spec(action="list") to locate the correct spec_id',
371
+ request_id=request_id,
372
+ code=ErrorCode.MISSING_REQUIRED,
373
+ )
374
+
375
+ if force is not None and not isinstance(force, bool):
376
+ return _validation_error(
377
+ action=action,
378
+ field="force",
379
+ message="Force flag must be boolean",
380
+ request_id=request_id,
381
+ )
382
+
383
+ if path is not None and not isinstance(path, str):
384
+ return _validation_error(
385
+ action=action,
386
+ field="path",
387
+ message="Workspace path must be a string",
388
+ request_id=request_id,
389
+ )
390
+
391
+ specs_dir = _resolve_specs_dir(config, path)
392
+ if specs_dir is None:
393
+ return _missing_specs_dir_response(request_id)
394
+
395
+ audit_log(
396
+ "tool_invocation",
397
+ tool="lifecycle",
398
+ action=action,
399
+ spec_id=spec_id.strip(),
400
+ force=bool(force),
401
+ )
402
+
403
+ start = time.perf_counter()
404
+ try:
405
+ result = complete_spec(spec_id.strip(), specs_dir, force=bool(force))
406
+ except Exception as exc: # pragma: no cover - defensive guard
407
+ logger.exception("Unexpected error completing spec")
408
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
409
+ return asdict(
410
+ error_response(
411
+ sanitize_error_message(exc, context="lifecycle"),
412
+ error_code=ErrorCode.INTERNAL_ERROR,
413
+ error_type=ErrorType.INTERNAL,
414
+ remediation="Inspect server logs for lifecycle completion failures",
415
+ request_id=request_id,
416
+ )
417
+ )
418
+
419
+ elapsed_ms = (time.perf_counter() - start) * 1000
420
+ return _move_result_response(
421
+ action=action,
422
+ result=result,
423
+ request_id=request_id,
424
+ elapsed_ms=elapsed_ms,
425
+ )
426
+
427
+
428
+ def _handle_archive(
429
+ *,
430
+ config: ServerConfig,
431
+ spec_id: Optional[str] = None,
432
+ path: Optional[str] = None,
433
+ ) -> dict:
434
+ action = "archive"
435
+ request_id = _request_id()
436
+
437
+ if not isinstance(spec_id, str) or not spec_id.strip():
438
+ return _validation_error(
439
+ action=action,
440
+ field="spec_id",
441
+ message="Provide a non-empty spec identifier",
442
+ remediation='Call spec(action="list") to locate the correct spec_id',
443
+ request_id=request_id,
444
+ code=ErrorCode.MISSING_REQUIRED,
445
+ )
446
+
447
+ if path is not None and not isinstance(path, str):
448
+ return _validation_error(
449
+ action=action,
450
+ field="path",
451
+ message="Workspace path must be a string",
452
+ request_id=request_id,
453
+ )
454
+
455
+ specs_dir = _resolve_specs_dir(config, path)
456
+ if specs_dir is None:
457
+ return _missing_specs_dir_response(request_id)
458
+
459
+ audit_log(
460
+ "tool_invocation",
461
+ tool="lifecycle",
462
+ action=action,
463
+ spec_id=spec_id.strip(),
464
+ )
465
+
466
+ start = time.perf_counter()
467
+ try:
468
+ result = archive_spec(spec_id.strip(), specs_dir)
469
+ except Exception as exc: # pragma: no cover - defensive guard
470
+ logger.exception("Unexpected error archiving spec")
471
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
472
+ return asdict(
473
+ error_response(
474
+ sanitize_error_message(exc, context="lifecycle"),
475
+ error_code=ErrorCode.INTERNAL_ERROR,
476
+ error_type=ErrorType.INTERNAL,
477
+ remediation="Inspect server logs for lifecycle archive failures",
478
+ request_id=request_id,
479
+ )
480
+ )
481
+
482
+ elapsed_ms = (time.perf_counter() - start) * 1000
483
+ return _move_result_response(
484
+ action=action,
485
+ result=result,
486
+ request_id=request_id,
487
+ elapsed_ms=elapsed_ms,
488
+ )
489
+
490
+
491
+ def _handle_state(
492
+ *,
493
+ config: ServerConfig,
494
+ spec_id: Optional[str] = None,
495
+ path: Optional[str] = None,
496
+ ) -> dict:
497
+ action = "state"
498
+ request_id = _request_id()
499
+
500
+ if not isinstance(spec_id, str) or not spec_id.strip():
501
+ return _validation_error(
502
+ action=action,
503
+ field="spec_id",
504
+ message="Provide a non-empty spec identifier",
505
+ remediation='Call spec(action="list") to locate the correct spec_id',
506
+ request_id=request_id,
507
+ code=ErrorCode.MISSING_REQUIRED,
508
+ )
509
+
510
+ if path is not None and not isinstance(path, str):
511
+ return _validation_error(
512
+ action=action,
513
+ field="path",
514
+ message="Workspace path must be a string",
515
+ request_id=request_id,
516
+ )
517
+
518
+ specs_dir = _resolve_specs_dir(config, path)
519
+ if specs_dir is None:
520
+ return _missing_specs_dir_response(request_id)
521
+
522
+ start = time.perf_counter()
523
+ try:
524
+ state = get_lifecycle_state(spec_id.strip(), specs_dir)
525
+ except Exception as exc: # pragma: no cover - defensive guard
526
+ logger.exception("Unexpected error fetching lifecycle state")
527
+ _metrics.counter(_metric_name(action), labels={"status": "exception"})
528
+ return asdict(
529
+ error_response(
530
+ sanitize_error_message(exc, context="lifecycle"),
531
+ error_code=ErrorCode.INTERNAL_ERROR,
532
+ error_type=ErrorType.INTERNAL,
533
+ remediation="Inspect server logs for lifecycle state failures",
534
+ request_id=request_id,
535
+ )
536
+ )
537
+
538
+ elapsed_ms = (time.perf_counter() - start) * 1000
539
+
540
+ if state is None:
541
+ _metrics.counter(_metric_name(action), labels={"status": "not_found"})
542
+ return asdict(
543
+ error_response(
544
+ f"Spec '{spec_id.strip()}' not found",
545
+ error_code=ErrorCode.SPEC_NOT_FOUND,
546
+ error_type=ErrorType.NOT_FOUND,
547
+ remediation='Verify the spec exists via spec(action="list")',
548
+ request_id=request_id,
549
+ )
550
+ )
551
+
552
+ _metrics.counter(_metric_name(action), labels={"status": "success"})
553
+ return _state_response(state, request_id=request_id, elapsed_ms=elapsed_ms)
554
+
555
+
556
+ _LIFECYCLE_ROUTER = ActionRouter(
557
+ tool_name="lifecycle",
558
+ actions=[
559
+ ActionDefinition(
560
+ name="move",
561
+ handler=_handle_move,
562
+ summary=_ACTION_SUMMARY["move"],
563
+ ),
564
+ ActionDefinition(
565
+ name="activate",
566
+ handler=_handle_activate,
567
+ summary=_ACTION_SUMMARY["activate"],
568
+ ),
569
+ ActionDefinition(
570
+ name="complete",
571
+ handler=_handle_complete,
572
+ summary=_ACTION_SUMMARY["complete"],
573
+ ),
574
+ ActionDefinition(
575
+ name="archive",
576
+ handler=_handle_archive,
577
+ summary=_ACTION_SUMMARY["archive"],
578
+ ),
579
+ ActionDefinition(
580
+ name="state",
581
+ handler=_handle_state,
582
+ summary=_ACTION_SUMMARY["state"],
583
+ ),
584
+ ],
585
+ )
586
+
587
+
588
+ def _dispatch_lifecycle_action(
589
+ *, action: str, payload: Dict[str, Any], config: ServerConfig
590
+ ) -> dict:
591
+ try:
592
+ return _LIFECYCLE_ROUTER.dispatch(action=action, config=config, **payload)
593
+ except ActionRouterError as exc:
594
+ request_id = _request_id()
595
+ allowed = ", ".join(exc.allowed_actions)
596
+ return asdict(
597
+ error_response(
598
+ f"Unsupported lifecycle action '{action}'. Allowed actions: {allowed}",
599
+ error_code=ErrorCode.VALIDATION_ERROR,
600
+ error_type=ErrorType.VALIDATION,
601
+ remediation=f"Use one of: {allowed}",
602
+ request_id=request_id,
603
+ )
604
+ )
605
+
606
+
607
+ def register_unified_lifecycle_tool(mcp: FastMCP, config: ServerConfig) -> None:
608
+ """Register the consolidated lifecycle tool."""
609
+
610
+ @canonical_tool(mcp, canonical_name="lifecycle")
611
+ @mcp_tool(tool_name="lifecycle", emit_metrics=True, audit=True)
612
+ def lifecycle(
613
+ action: str,
614
+ spec_id: Optional[str] = None,
615
+ to_folder: Optional[str] = None,
616
+ force: Optional[bool] = False,
617
+ path: Optional[str] = None,
618
+ ) -> dict:
619
+ payload = {
620
+ "spec_id": spec_id,
621
+ "to_folder": to_folder,
622
+ "force": force,
623
+ "path": path,
624
+ }
625
+ return _dispatch_lifecycle_action(action=action, payload=payload, config=config)
626
+
627
+ logger.debug("Registered unified lifecycle tool")
628
+
629
+
630
+ __all__ = [
631
+ "register_unified_lifecycle_tool",
632
+ ]