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.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|