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,1624 @@
1
+ """
2
+ Standard response contracts for MCP tool operations.
3
+ Provides consistent response structures across all foundry-mcp tools.
4
+
5
+ Response Schema Contract
6
+ ========================
7
+
8
+ All MCP tool responses follow a standard structure:
9
+
10
+ {
11
+ "success": bool, # Required: operation success/failure
12
+ "data": {...}, # Required: primary payload (empty dict on error)
13
+ "error": str | null, # Required: error message or null on success
14
+ "meta": { # Required: response metadata
15
+ "version": "response-v2",
16
+ "request_id": "req_abc123"?,
17
+ "warnings": ["..."]?,
18
+ "pagination": { ... }?,
19
+ "rate_limit": { ... }?,
20
+ "telemetry": { ... }?
21
+ }
22
+ }
23
+
24
+ Metadata Semantics
25
+ ------------------
26
+
27
+ Attach operational context through `meta` so every tool shares an identical
28
+ envelope. The standard keys are:
29
+
30
+ * `version` *(required)* – identifies the contract version (`response-v2`).
31
+ * `request_id` *(should)* – correlation identifier propagated through logs.
32
+ * `warnings` *(should)* – array of non-fatal issues for successful operations.
33
+ * `pagination` *(may)* – cursor information (`cursor`, `has_more`, `total_count`).
34
+ * `rate_limit` *(may)* – limit, remaining, reset timestamp, retry hints.
35
+ * `telemetry` *(may)* – timing/performance metrics, downstream call counts, etc.
36
+
37
+ Multi-Payload Tools
38
+ -------------------
39
+
40
+ Tools returning multiple payloads should nest each value under a named key:
41
+
42
+ data = {
43
+ "spec": {...}, # First payload
44
+ "tasks": [...], # Second payload
45
+ }
46
+
47
+ This ensures consumers can access each payload by name rather than relying
48
+ on position or implicit structure.
49
+
50
+ Edge Cases & Partial Payloads
51
+ -----------------------------
52
+
53
+ Empty Results (success=True):
54
+ When a query succeeds but finds no results, return success=True with
55
+ empty/partial data to distinguish from errors:
56
+
57
+ {"success": True, "data": {"tasks": [], "count": 0}, "error": None}
58
+
59
+ Not Found (success=False):
60
+ When the requested resource doesn't exist, return success=False:
61
+
62
+ {"success": False, "data": {}, "error": "Spec not found: my-spec"}
63
+
64
+ Blocked/Conditional States (success=True):
65
+ Dependency checks and similar queries return success=True with state info:
66
+
67
+ {
68
+ "success": True,
69
+ "data": {
70
+ "task_id": "task-1-2",
71
+ "can_start": False,
72
+ "blocked_by": [{"id": "task-1-1", "status": "pending"}]
73
+ },
74
+ "error": None,
75
+ "meta": {
76
+ "version": "response-v2",
77
+ "warnings": ["Task currently blocked"]
78
+ }
79
+ }
80
+
81
+ Key Principle:
82
+ - `success=True` means the operation executed correctly (even if the result is empty).
83
+ - `success=False` means the operation failed to execute; include actionable error details.
84
+ - Keep business data inside `data` and operational context inside `meta`.
85
+ """
86
+
87
+ import json
88
+ import logging
89
+ import subprocess
90
+ from dataclasses import dataclass, field
91
+ from enum import Enum
92
+ from typing import Any, Dict, Mapping, Optional, Sequence, Union
93
+
94
+ from foundry_mcp.core.context import get_correlation_id
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+
99
+ class ErrorCode(str, Enum):
100
+ """Machine-readable error codes for MCP tool responses.
101
+
102
+ Use these canonical codes in `error_code` fields to enable consistent
103
+ client-side error handling. Codes follow SCREAMING_SNAKE_CASE convention.
104
+
105
+ Categories:
106
+ - Validation (input errors)
107
+ - Resource (not found, conflict)
108
+ - Access (auth, permissions, rate limits)
109
+ - System (internal, unavailable)
110
+ """
111
+
112
+ # Validation errors
113
+ VALIDATION_ERROR = "VALIDATION_ERROR"
114
+ INVALID_FORMAT = "INVALID_FORMAT"
115
+ MISSING_REQUIRED = "MISSING_REQUIRED"
116
+ INVALID_PARENT = "INVALID_PARENT"
117
+ INVALID_POSITION = "INVALID_POSITION"
118
+ INVALID_REGEX_PATTERN = "INVALID_REGEX_PATTERN"
119
+ PATTERN_TOO_BROAD = "PATTERN_TOO_BROAD"
120
+
121
+ # Resource errors
122
+ NOT_FOUND = "NOT_FOUND"
123
+ SPEC_NOT_FOUND = "SPEC_NOT_FOUND"
124
+ TASK_NOT_FOUND = "TASK_NOT_FOUND"
125
+ PHASE_NOT_FOUND = "PHASE_NOT_FOUND"
126
+ DEPENDENCY_NOT_FOUND = "DEPENDENCY_NOT_FOUND"
127
+ BACKUP_NOT_FOUND = "BACKUP_NOT_FOUND"
128
+ NO_MATCHES_FOUND = "NO_MATCHES_FOUND"
129
+ DUPLICATE_ENTRY = "DUPLICATE_ENTRY"
130
+ CONFLICT = "CONFLICT"
131
+ CIRCULAR_DEPENDENCY = "CIRCULAR_DEPENDENCY"
132
+ SELF_REFERENCE = "SELF_REFERENCE"
133
+
134
+ # Access errors
135
+ UNAUTHORIZED = "UNAUTHORIZED"
136
+ FORBIDDEN = "FORBIDDEN"
137
+ RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
138
+ FEATURE_DISABLED = "FEATURE_DISABLED"
139
+
140
+ # System errors
141
+ INTERNAL_ERROR = "INTERNAL_ERROR"
142
+ UNAVAILABLE = "UNAVAILABLE"
143
+ RESOURCE_BUSY = "RESOURCE_BUSY"
144
+ BACKUP_CORRUPTED = "BACKUP_CORRUPTED"
145
+ ROLLBACK_FAILED = "ROLLBACK_FAILED"
146
+ COMPARISON_FAILED = "COMPARISON_FAILED"
147
+ OPERATION_FAILED = "OPERATION_FAILED"
148
+
149
+ # AI/LLM Provider errors
150
+ AI_NO_PROVIDER = "AI_NO_PROVIDER"
151
+ AI_PROVIDER_TIMEOUT = "AI_PROVIDER_TIMEOUT"
152
+ AI_PROVIDER_ERROR = "AI_PROVIDER_ERROR"
153
+ AI_CONTEXT_TOO_LARGE = "AI_CONTEXT_TOO_LARGE"
154
+ AI_PROMPT_NOT_FOUND = "AI_PROMPT_NOT_FOUND"
155
+ AI_CACHE_STALE = "AI_CACHE_STALE"
156
+
157
+
158
+ class ErrorType(str, Enum):
159
+ """Error categories for routing and client-side handling.
160
+
161
+ Each type corresponds to an HTTP status code analog and indicates
162
+ whether the operation should be retried.
163
+
164
+ See docs/codebase_standards/mcp_response_schema.md for the full mapping.
165
+ """
166
+
167
+ VALIDATION = "validation" # 400 - No retry, fix input
168
+ AUTHENTICATION = "authentication" # 401 - No retry, re-authenticate
169
+ AUTHORIZATION = "authorization" # 403 - No retry
170
+ NOT_FOUND = "not_found" # 404 - No retry
171
+ CONFLICT = "conflict" # 409 - Maybe retry, check state
172
+ RATE_LIMIT = "rate_limit" # 429 - Yes, after delay
173
+ FEATURE_FLAG = "feature_flag" # 403 - No retry, check flag status
174
+ INTERNAL = "internal" # 500 - Yes, with backoff
175
+ UNAVAILABLE = "unavailable" # 503 - Yes, with backoff
176
+ AI_PROVIDER = "ai_provider" # AI-specific - Retry varies by error
177
+
178
+
179
+ @dataclass
180
+ class ToolResponse:
181
+ """
182
+ Standard response structure for MCP tool operations.
183
+
184
+ All tool handlers should return data that can be serialized to this format,
185
+ ensuring consistent API responses across the codebase.
186
+
187
+ Attributes:
188
+ success: Whether the operation completed successfully
189
+ data: The primary payload (operation-specific structured data)
190
+ error: Error message if success is False, None otherwise
191
+ meta: Response metadata including version identifier
192
+ """
193
+
194
+ success: bool
195
+ data: Dict[str, Any] = field(default_factory=dict)
196
+ error: Optional[str] = None
197
+ meta: Dict[str, Any] = field(default_factory=lambda: {"version": "response-v2"})
198
+
199
+
200
+ def _build_meta(
201
+ *,
202
+ request_id: Optional[str] = None,
203
+ warnings: Optional[Sequence[str]] = None,
204
+ pagination: Optional[Mapping[str, Any]] = None,
205
+ rate_limit: Optional[Mapping[str, Any]] = None,
206
+ telemetry: Optional[Mapping[str, Any]] = None,
207
+ extra: Optional[Mapping[str, Any]] = None,
208
+ auto_inject_request_id: bool = True,
209
+ ) -> Dict[str, Any]:
210
+ """Construct a metadata payload that always includes the response version.
211
+
212
+ Args:
213
+ request_id: Explicit correlation ID (takes precedence if provided)
214
+ warnings: Non-fatal issues to surface
215
+ pagination: Cursor metadata for list results
216
+ rate_limit: Rate limit state
217
+ telemetry: Timing/performance metadata
218
+ extra: Arbitrary extra metadata to merge
219
+ auto_inject_request_id: If True (default), auto-inject correlation_id
220
+ from context when request_id is not explicitly provided
221
+ """
222
+ meta: Dict[str, Any] = {"version": "response-v2"}
223
+
224
+ # Auto-inject request_id from context if not explicitly provided
225
+ effective_request_id = request_id
226
+ if effective_request_id is None and auto_inject_request_id:
227
+ effective_request_id = get_correlation_id() or None
228
+
229
+ if effective_request_id:
230
+ meta["request_id"] = effective_request_id
231
+ if warnings:
232
+ meta["warnings"] = list(warnings)
233
+ if pagination:
234
+ meta["pagination"] = dict(pagination)
235
+ if rate_limit:
236
+ meta["rate_limit"] = dict(rate_limit)
237
+ if telemetry:
238
+ meta["telemetry"] = dict(telemetry)
239
+ if extra:
240
+ meta.update(dict(extra))
241
+
242
+ return meta
243
+
244
+
245
+ def success_response(
246
+ data: Optional[Mapping[str, Any]] = None,
247
+ *,
248
+ warnings: Optional[Sequence[str]] = None,
249
+ pagination: Optional[Mapping[str, Any]] = None,
250
+ rate_limit: Optional[Mapping[str, Any]] = None,
251
+ telemetry: Optional[Mapping[str, Any]] = None,
252
+ request_id: Optional[str] = None,
253
+ meta: Optional[Mapping[str, Any]] = None,
254
+ **fields: Any,
255
+ ) -> ToolResponse:
256
+ """Create a standardized success response.
257
+
258
+ Args:
259
+ data: Optional mapping used as the base payload.
260
+ warnings: Non-fatal issues to surface in ``meta.warnings``.
261
+ pagination: Cursor metadata for list results.
262
+ rate_limit: Rate limit state (limit, remaining, reset_at, etc.).
263
+ telemetry: Timing/performance metadata.
264
+ request_id: Correlation identifier propagated through logs/traces.
265
+ meta: Arbitrary extra metadata to merge into ``meta``.
266
+ **fields: Additional payload fields (shorthand for ``data.update``).
267
+ """
268
+ payload: Dict[str, Any] = {}
269
+ if data:
270
+ payload.update(dict(data))
271
+ if fields:
272
+ payload.update(fields)
273
+
274
+ meta_payload = _build_meta(
275
+ request_id=request_id,
276
+ warnings=warnings,
277
+ pagination=pagination,
278
+ rate_limit=rate_limit,
279
+ telemetry=telemetry,
280
+ extra=meta,
281
+ )
282
+
283
+ return ToolResponse(success=True, data=payload, error=None, meta=meta_payload)
284
+
285
+
286
+ def error_response(
287
+ message: str,
288
+ *,
289
+ data: Optional[Mapping[str, Any]] = None,
290
+ error_code: Optional[Union[ErrorCode, str]] = None,
291
+ error_type: Optional[Union[ErrorType, str]] = None,
292
+ remediation: Optional[str] = None,
293
+ details: Optional[Mapping[str, Any]] = None,
294
+ request_id: Optional[str] = None,
295
+ rate_limit: Optional[Mapping[str, Any]] = None,
296
+ telemetry: Optional[Mapping[str, Any]] = None,
297
+ meta: Optional[Mapping[str, Any]] = None,
298
+ ) -> ToolResponse:
299
+ """Create a standardized error response.
300
+
301
+ Args:
302
+ message: Human-readable description of the failure.
303
+ data: Optional mapping with additional machine-readable context.
304
+ error_code: Canonical error code (use ``ErrorCode`` enum or string,
305
+ e.g., ``ErrorCode.VALIDATION_ERROR`` or ``"VALIDATION_ERROR"``).
306
+ error_type: Error category for routing (use ``ErrorType`` enum or string,
307
+ e.g., ``ErrorType.VALIDATION`` or ``"validation"``).
308
+ remediation: User-facing guidance on how to fix the issue.
309
+ details: Nested structure describing validation failures or metadata.
310
+ request_id: Correlation identifier propagated through logs/traces.
311
+ rate_limit: Rate limit state to help clients back off correctly.
312
+ telemetry: Timing/performance metadata captured before failure.
313
+ meta: Arbitrary extra metadata to merge into ``meta``.
314
+
315
+ Example:
316
+ >>> error_response(
317
+ ... "Validation failed: spec_id is required",
318
+ ... error_code=ErrorCode.MISSING_REQUIRED,
319
+ ... error_type=ErrorType.VALIDATION,
320
+ ... remediation="Provide a non-empty spec_id parameter",
321
+ ... )
322
+ """
323
+ payload: Dict[str, Any] = {}
324
+ if data:
325
+ payload.update(dict(data))
326
+
327
+ effective_error_code: Union[ErrorCode, str] = (
328
+ error_code if error_code is not None else ErrorCode.INTERNAL_ERROR
329
+ )
330
+ effective_error_type: Union[ErrorType, str] = (
331
+ error_type if error_type is not None else ErrorType.INTERNAL
332
+ )
333
+
334
+ if "error_code" not in payload:
335
+ payload["error_code"] = (
336
+ effective_error_code.value
337
+ if isinstance(effective_error_code, Enum)
338
+ else effective_error_code
339
+ )
340
+ if "error_type" not in payload:
341
+ payload["error_type"] = (
342
+ effective_error_type.value
343
+ if isinstance(effective_error_type, Enum)
344
+ else effective_error_type
345
+ )
346
+ if remediation is not None and "remediation" not in payload:
347
+ payload["remediation"] = remediation
348
+ if details and "details" not in payload:
349
+ payload["details"] = dict(details)
350
+
351
+ meta_payload = _build_meta(
352
+ request_id=request_id,
353
+ rate_limit=rate_limit,
354
+ telemetry=telemetry,
355
+ extra=meta,
356
+ )
357
+
358
+ return ToolResponse(success=False, data=payload, error=message, meta=meta_payload)
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Specialized Error Helpers
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ def validation_error(
367
+ message: str,
368
+ *,
369
+ field: Optional[str] = None,
370
+ details: Optional[Mapping[str, Any]] = None,
371
+ remediation: Optional[str] = None,
372
+ request_id: Optional[str] = None,
373
+ ) -> ToolResponse:
374
+ """Create a validation error response (HTTP 400 analog).
375
+
376
+ Args:
377
+ message: Human-readable description of the validation failure.
378
+ field: The field that failed validation.
379
+ details: Additional context (e.g., constraint violated, value received).
380
+ remediation: Guidance on how to fix the input.
381
+ request_id: Correlation identifier.
382
+
383
+ Example:
384
+ >>> validation_error(
385
+ ... "Invalid email format",
386
+ ... field="email",
387
+ ... remediation="Provide email in format: user@domain.com",
388
+ ... )
389
+ """
390
+ error_details = dict(details) if details else {}
391
+ if field and "field" not in error_details:
392
+ error_details["field"] = field
393
+
394
+ return error_response(
395
+ message,
396
+ error_code=ErrorCode.VALIDATION_ERROR,
397
+ error_type=ErrorType.VALIDATION,
398
+ details=error_details if error_details else None,
399
+ remediation=remediation,
400
+ request_id=request_id,
401
+ )
402
+
403
+
404
+ def not_found_error(
405
+ resource_type: str,
406
+ resource_id: str,
407
+ *,
408
+ remediation: Optional[str] = None,
409
+ request_id: Optional[str] = None,
410
+ ) -> ToolResponse:
411
+ """Create a not found error response (HTTP 404 analog).
412
+
413
+ Args:
414
+ resource_type: Type of resource (e.g., "Spec", "Task", "User").
415
+ resource_id: Identifier of the missing resource.
416
+ remediation: Guidance on how to resolve (defaults to verification hint).
417
+ request_id: Correlation identifier.
418
+
419
+ Example:
420
+ >>> not_found_error("Spec", "my-spec-001")
421
+ """
422
+ return error_response(
423
+ f"{resource_type} '{resource_id}' not found",
424
+ error_code=ErrorCode.NOT_FOUND,
425
+ error_type=ErrorType.NOT_FOUND,
426
+ data={"resource_type": resource_type, "resource_id": resource_id},
427
+ remediation=remediation or f"Verify the {resource_type.lower()} ID exists.",
428
+ request_id=request_id,
429
+ )
430
+
431
+
432
+ def rate_limit_error(
433
+ limit: int,
434
+ period: str,
435
+ retry_after_seconds: int,
436
+ *,
437
+ remediation: Optional[str] = None,
438
+ request_id: Optional[str] = None,
439
+ ) -> ToolResponse:
440
+ """Create a rate limit error response (HTTP 429 analog).
441
+
442
+ Args:
443
+ limit: Maximum requests allowed in the period.
444
+ period: Time window (e.g., "minute", "hour").
445
+ retry_after_seconds: Seconds until client can retry.
446
+ remediation: Guidance on how to proceed.
447
+ request_id: Correlation identifier.
448
+
449
+ Example:
450
+ >>> rate_limit_error(100, "minute", 45)
451
+ """
452
+ return error_response(
453
+ f"Rate limit exceeded: {limit} requests per {period}",
454
+ error_code=ErrorCode.RATE_LIMIT_EXCEEDED,
455
+ error_type=ErrorType.RATE_LIMIT,
456
+ data={"retry_after_seconds": retry_after_seconds},
457
+ rate_limit={
458
+ "limit": limit,
459
+ "period": period,
460
+ "retry_after": retry_after_seconds,
461
+ },
462
+ remediation=remediation
463
+ or f"Wait {retry_after_seconds} seconds before retrying.",
464
+ request_id=request_id,
465
+ )
466
+
467
+
468
+ def unauthorized_error(
469
+ message: str = "Authentication required",
470
+ *,
471
+ remediation: Optional[str] = None,
472
+ request_id: Optional[str] = None,
473
+ ) -> ToolResponse:
474
+ """Create an unauthorized error response (HTTP 401 analog).
475
+
476
+ Args:
477
+ message: Human-readable description.
478
+ remediation: Guidance on how to authenticate.
479
+ request_id: Correlation identifier.
480
+
481
+ Example:
482
+ >>> unauthorized_error("Invalid API key")
483
+ """
484
+ return error_response(
485
+ message,
486
+ error_code=ErrorCode.UNAUTHORIZED,
487
+ error_type=ErrorType.AUTHENTICATION,
488
+ remediation=remediation or "Provide valid authentication credentials.",
489
+ request_id=request_id,
490
+ )
491
+
492
+
493
+ def forbidden_error(
494
+ message: str,
495
+ *,
496
+ required_permission: Optional[str] = None,
497
+ remediation: Optional[str] = None,
498
+ request_id: Optional[str] = None,
499
+ ) -> ToolResponse:
500
+ """Create a forbidden error response (HTTP 403 analog).
501
+
502
+ Args:
503
+ message: Human-readable description.
504
+ required_permission: The permission needed for this operation.
505
+ remediation: Guidance on how to obtain access.
506
+ request_id: Correlation identifier.
507
+
508
+ Example:
509
+ >>> forbidden_error(
510
+ ... "Cannot delete project",
511
+ ... required_permission="project:delete",
512
+ ... )
513
+ """
514
+ data: Dict[str, Any] = {}
515
+ if required_permission:
516
+ data["required_permission"] = required_permission
517
+
518
+ return error_response(
519
+ message,
520
+ error_code=ErrorCode.FORBIDDEN,
521
+ error_type=ErrorType.AUTHORIZATION,
522
+ data=data if data else None,
523
+ remediation=remediation
524
+ or "Request appropriate permissions from the resource owner.",
525
+ request_id=request_id,
526
+ )
527
+
528
+
529
+ def conflict_error(
530
+ message: str,
531
+ *,
532
+ details: Optional[Mapping[str, Any]] = None,
533
+ remediation: Optional[str] = None,
534
+ request_id: Optional[str] = None,
535
+ ) -> ToolResponse:
536
+ """Create a conflict error response (HTTP 409 analog).
537
+
538
+ Args:
539
+ message: Human-readable description of the conflict.
540
+ details: Context about the conflicting state.
541
+ remediation: Guidance on how to resolve the conflict.
542
+ request_id: Correlation identifier.
543
+
544
+ Example:
545
+ >>> conflict_error(
546
+ ... "Resource already exists",
547
+ ... details={"existing_id": "spec-001"},
548
+ ... )
549
+ """
550
+ return error_response(
551
+ message,
552
+ error_code=ErrorCode.CONFLICT,
553
+ error_type=ErrorType.CONFLICT,
554
+ details=details,
555
+ remediation=remediation or "Check current state and retry if appropriate.",
556
+ request_id=request_id,
557
+ )
558
+
559
+
560
+ def internal_error(
561
+ message: str = "An internal error occurred",
562
+ *,
563
+ request_id: Optional[str] = None,
564
+ ) -> ToolResponse:
565
+ """Create an internal error response (HTTP 500 analog).
566
+
567
+ Args:
568
+ message: Human-readable description (keep vague for security).
569
+ request_id: Correlation identifier for log correlation.
570
+
571
+ Example:
572
+ >>> internal_error(request_id="req_abc123")
573
+ """
574
+ remediation = "Please try again. If the problem persists, contact support."
575
+ if request_id:
576
+ remediation += f" Reference: {request_id}"
577
+
578
+ return error_response(
579
+ message,
580
+ error_code=ErrorCode.INTERNAL_ERROR,
581
+ error_type=ErrorType.INTERNAL,
582
+ remediation=remediation,
583
+ request_id=request_id,
584
+ )
585
+
586
+
587
+ def unavailable_error(
588
+ message: str = "Service temporarily unavailable",
589
+ *,
590
+ retry_after_seconds: Optional[int] = None,
591
+ request_id: Optional[str] = None,
592
+ ) -> ToolResponse:
593
+ """Create an unavailable error response (HTTP 503 analog).
594
+
595
+ Args:
596
+ message: Human-readable description.
597
+ retry_after_seconds: Suggested retry delay.
598
+ request_id: Correlation identifier.
599
+
600
+ Example:
601
+ >>> unavailable_error("Database maintenance in progress", retry_after_seconds=300)
602
+ """
603
+ data: Dict[str, Any] = {}
604
+ if retry_after_seconds:
605
+ data["retry_after_seconds"] = retry_after_seconds
606
+
607
+ remediation = "Please retry with exponential backoff."
608
+ if retry_after_seconds:
609
+ remediation = f"Retry after {retry_after_seconds} seconds."
610
+
611
+ return error_response(
612
+ message,
613
+ error_code=ErrorCode.UNAVAILABLE,
614
+ error_type=ErrorType.UNAVAILABLE,
615
+ data=data if data else None,
616
+ remediation=remediation,
617
+ request_id=request_id,
618
+ )
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # Spec Modification Error Helpers
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ def circular_dependency_error(
627
+ task_id: str,
628
+ target_id: str,
629
+ *,
630
+ cycle_path: Optional[Sequence[str]] = None,
631
+ remediation: Optional[str] = None,
632
+ request_id: Optional[str] = None,
633
+ ) -> ToolResponse:
634
+ """Create an error response for circular dependency detection.
635
+
636
+ Use when a move or dependency operation would create a cycle.
637
+
638
+ Args:
639
+ task_id: The task being moved or modified.
640
+ target_id: The target parent or dependency that would create a cycle.
641
+ cycle_path: Optional sequence showing the dependency cycle path.
642
+ remediation: Guidance on how to resolve.
643
+ request_id: Correlation identifier.
644
+
645
+ Example:
646
+ >>> circular_dependency_error("task-3", "task-1", cycle_path=["task-1", "task-2", "task-3"])
647
+ """
648
+ data: Dict[str, Any] = {
649
+ "task_id": task_id,
650
+ "target_id": target_id,
651
+ }
652
+ if cycle_path:
653
+ data["cycle_path"] = list(cycle_path)
654
+
655
+ return error_response(
656
+ f"Circular dependency detected: {task_id} cannot depend on {target_id}",
657
+ error_code=ErrorCode.CIRCULAR_DEPENDENCY,
658
+ error_type=ErrorType.CONFLICT,
659
+ data=data,
660
+ remediation=remediation
661
+ or "Remove an existing dependency to break the cycle before adding this one.",
662
+ request_id=request_id,
663
+ )
664
+
665
+
666
+ def invalid_parent_error(
667
+ task_id: str,
668
+ target_parent: str,
669
+ reason: str,
670
+ *,
671
+ valid_parents: Optional[Sequence[str]] = None,
672
+ remediation: Optional[str] = None,
673
+ request_id: Optional[str] = None,
674
+ ) -> ToolResponse:
675
+ """Create an error response for invalid parent in move operation.
676
+
677
+ Use when a task cannot be moved to the specified parent.
678
+
679
+ Args:
680
+ task_id: The task being moved.
681
+ target_parent: The invalid target parent.
682
+ reason: Why the parent is invalid (e.g., "is a task, not a phase").
683
+ valid_parents: Optional list of valid parent IDs.
684
+ remediation: Guidance on how to resolve.
685
+ request_id: Correlation identifier.
686
+
687
+ Example:
688
+ >>> invalid_parent_error("task-3-1", "task-2-1", "target is a task, not a phase")
689
+ """
690
+ data: Dict[str, Any] = {
691
+ "task_id": task_id,
692
+ "target_parent": target_parent,
693
+ "reason": reason,
694
+ }
695
+ if valid_parents:
696
+ data["valid_parents"] = list(valid_parents)
697
+
698
+ return error_response(
699
+ f"Invalid parent '{target_parent}' for task '{task_id}': {reason}",
700
+ error_code=ErrorCode.INVALID_PARENT,
701
+ error_type=ErrorType.VALIDATION,
702
+ data=data,
703
+ remediation=remediation or "Specify a valid phase or parent task as the target.",
704
+ request_id=request_id,
705
+ )
706
+
707
+
708
+ def self_reference_error(
709
+ task_id: str,
710
+ operation: str,
711
+ *,
712
+ remediation: Optional[str] = None,
713
+ request_id: Optional[str] = None,
714
+ ) -> ToolResponse:
715
+ """Create an error response for self-referencing operations.
716
+
717
+ Use when a task references itself in dependencies or move operations.
718
+
719
+ Args:
720
+ task_id: The task that references itself.
721
+ operation: The operation attempted (e.g., "add-dependency", "move").
722
+ remediation: Guidance on how to resolve.
723
+ request_id: Correlation identifier.
724
+
725
+ Example:
726
+ >>> self_reference_error("task-1-1", "add-dependency")
727
+ """
728
+ return error_response(
729
+ f"Task '{task_id}' cannot reference itself in {operation}",
730
+ error_code=ErrorCode.SELF_REFERENCE,
731
+ error_type=ErrorType.VALIDATION,
732
+ data={"task_id": task_id, "operation": operation},
733
+ remediation=remediation or "Specify a different task ID as the target.",
734
+ request_id=request_id,
735
+ )
736
+
737
+
738
+ def dependency_not_found_error(
739
+ task_id: str,
740
+ dependency_id: str,
741
+ *,
742
+ remediation: Optional[str] = None,
743
+ request_id: Optional[str] = None,
744
+ ) -> ToolResponse:
745
+ """Create an error response for missing dependency in remove operation.
746
+
747
+ Use when trying to remove a dependency that doesn't exist.
748
+
749
+ Args:
750
+ task_id: The task being modified.
751
+ dependency_id: The dependency that wasn't found.
752
+ remediation: Guidance on how to resolve.
753
+ request_id: Correlation identifier.
754
+
755
+ Example:
756
+ >>> dependency_not_found_error("task-1-1", "task-2-1")
757
+ """
758
+ return error_response(
759
+ f"Dependency '{dependency_id}' not found on task '{task_id}'",
760
+ error_code=ErrorCode.DEPENDENCY_NOT_FOUND,
761
+ error_type=ErrorType.NOT_FOUND,
762
+ data={"task_id": task_id, "dependency_id": dependency_id},
763
+ remediation=remediation
764
+ or "Check existing dependencies using task info before removing.",
765
+ request_id=request_id,
766
+ )
767
+
768
+
769
+ def invalid_position_error(
770
+ item_id: str,
771
+ position: int,
772
+ max_position: int,
773
+ *,
774
+ remediation: Optional[str] = None,
775
+ request_id: Optional[str] = None,
776
+ ) -> ToolResponse:
777
+ """Create an error response for invalid position in move/reorder operation.
778
+
779
+ Use when the specified position is out of valid range.
780
+
781
+ Args:
782
+ item_id: The item being moved (phase or task ID).
783
+ position: The invalid position specified.
784
+ max_position: The maximum valid position.
785
+ remediation: Guidance on how to resolve.
786
+ request_id: Correlation identifier.
787
+
788
+ Example:
789
+ >>> invalid_position_error("phase-3", 10, 5)
790
+ """
791
+ return error_response(
792
+ f"Invalid position {position} for '{item_id}': must be 1-{max_position}",
793
+ error_code=ErrorCode.INVALID_POSITION,
794
+ error_type=ErrorType.VALIDATION,
795
+ data={
796
+ "item_id": item_id,
797
+ "position": position,
798
+ "max_position": max_position,
799
+ "valid_range": f"1-{max_position}",
800
+ },
801
+ remediation=remediation or f"Specify a position between 1 and {max_position}.",
802
+ request_id=request_id,
803
+ )
804
+
805
+
806
+ def invalid_regex_error(
807
+ pattern: str,
808
+ error_detail: str,
809
+ *,
810
+ remediation: Optional[str] = None,
811
+ request_id: Optional[str] = None,
812
+ ) -> ToolResponse:
813
+ """Create an error response for invalid regex pattern.
814
+
815
+ Use when a find/replace pattern is not valid regex.
816
+
817
+ Args:
818
+ pattern: The invalid regex pattern.
819
+ error_detail: The regex error message.
820
+ remediation: Guidance on how to fix the pattern.
821
+ request_id: Correlation identifier.
822
+
823
+ Example:
824
+ >>> invalid_regex_error("[unclosed", "unterminated character set")
825
+ """
826
+ return error_response(
827
+ f"Invalid regex pattern: {error_detail}",
828
+ error_code=ErrorCode.INVALID_REGEX_PATTERN,
829
+ error_type=ErrorType.VALIDATION,
830
+ data={"pattern": pattern, "error_detail": error_detail},
831
+ remediation=remediation
832
+ or "Check regex syntax. Use raw strings and escape special characters.",
833
+ request_id=request_id,
834
+ )
835
+
836
+
837
+ def pattern_too_broad_error(
838
+ pattern: str,
839
+ match_count: int,
840
+ max_matches: int,
841
+ *,
842
+ remediation: Optional[str] = None,
843
+ request_id: Optional[str] = None,
844
+ ) -> ToolResponse:
845
+ """Create an error response for overly broad patterns.
846
+
847
+ Use when a find/replace pattern matches too many items.
848
+
849
+ Args:
850
+ pattern: The pattern that matched too broadly.
851
+ match_count: Number of matches found.
852
+ max_matches: Maximum allowed matches.
853
+ remediation: Guidance on how to narrow the pattern.
854
+ request_id: Correlation identifier.
855
+
856
+ Example:
857
+ >>> pattern_too_broad_error(".*", 500, 100)
858
+ """
859
+ return error_response(
860
+ f"Pattern too broad: {match_count} matches exceeds limit of {max_matches}",
861
+ error_code=ErrorCode.PATTERN_TOO_BROAD,
862
+ error_type=ErrorType.VALIDATION,
863
+ data={
864
+ "pattern": pattern,
865
+ "match_count": match_count,
866
+ "max_matches": max_matches,
867
+ },
868
+ remediation=remediation
869
+ or "Use a more specific pattern or apply to a narrower scope.",
870
+ request_id=request_id,
871
+ )
872
+
873
+
874
+ def no_matches_error(
875
+ pattern: str,
876
+ scope: str,
877
+ *,
878
+ remediation: Optional[str] = None,
879
+ request_id: Optional[str] = None,
880
+ ) -> ToolResponse:
881
+ """Create an error response for patterns with no matches.
882
+
883
+ Use when a find/replace pattern matches nothing.
884
+
885
+ Args:
886
+ pattern: The pattern that found no matches.
887
+ scope: Where the search was performed (e.g., "spec", "phase-1").
888
+ remediation: Guidance on what to check.
889
+ request_id: Correlation identifier.
890
+
891
+ Example:
892
+ >>> no_matches_error("deprecated_function", "spec my-spec-001")
893
+ """
894
+ return error_response(
895
+ f"No matches found for pattern '{pattern}' in {scope}",
896
+ error_code=ErrorCode.NO_MATCHES_FOUND,
897
+ error_type=ErrorType.NOT_FOUND,
898
+ data={"pattern": pattern, "scope": scope},
899
+ remediation=remediation
900
+ or "Verify the pattern and scope. Use dry-run to preview matches.",
901
+ request_id=request_id,
902
+ )
903
+
904
+
905
+ def backup_not_found_error(
906
+ spec_id: str,
907
+ backup_id: Optional[str] = None,
908
+ *,
909
+ available_backups: Optional[Sequence[str]] = None,
910
+ remediation: Optional[str] = None,
911
+ request_id: Optional[str] = None,
912
+ ) -> ToolResponse:
913
+ """Create an error response for missing backup.
914
+
915
+ Use when a rollback or diff references a non-existent backup.
916
+
917
+ Args:
918
+ spec_id: The spec whose backup is missing.
919
+ backup_id: The specific backup ID that wasn't found.
920
+ available_backups: Optional list of available backup IDs.
921
+ remediation: Guidance on how to resolve.
922
+ request_id: Correlation identifier.
923
+
924
+ Example:
925
+ >>> backup_not_found_error("my-spec-001", "backup-2024-01-15")
926
+ """
927
+ data: Dict[str, Any] = {"spec_id": spec_id}
928
+ if backup_id:
929
+ data["backup_id"] = backup_id
930
+ if available_backups:
931
+ data["available_backups"] = list(available_backups)
932
+
933
+ message = f"Backup not found for spec '{spec_id}'"
934
+ if backup_id:
935
+ message = f"Backup '{backup_id}' not found for spec '{spec_id}'"
936
+
937
+ return error_response(
938
+ message,
939
+ error_code=ErrorCode.BACKUP_NOT_FOUND,
940
+ error_type=ErrorType.NOT_FOUND,
941
+ data=data,
942
+ remediation=remediation or "List available backups using spec action='history'.",
943
+ request_id=request_id,
944
+ )
945
+
946
+
947
+ def backup_corrupted_error(
948
+ spec_id: str,
949
+ backup_id: str,
950
+ error_detail: str,
951
+ *,
952
+ remediation: Optional[str] = None,
953
+ request_id: Optional[str] = None,
954
+ ) -> ToolResponse:
955
+ """Create an error response for corrupted backup.
956
+
957
+ Use when a backup file exists but cannot be loaded.
958
+
959
+ Args:
960
+ spec_id: The spec whose backup is corrupted.
961
+ backup_id: The corrupted backup identifier.
962
+ error_detail: Description of the corruption.
963
+ remediation: Guidance on how to recover.
964
+ request_id: Correlation identifier.
965
+
966
+ Example:
967
+ >>> backup_corrupted_error("my-spec", "backup-001", "Invalid JSON structure")
968
+ """
969
+ return error_response(
970
+ f"Backup '{backup_id}' for spec '{spec_id}' is corrupted: {error_detail}",
971
+ error_code=ErrorCode.BACKUP_CORRUPTED,
972
+ error_type=ErrorType.INTERNAL,
973
+ data={
974
+ "spec_id": spec_id,
975
+ "backup_id": backup_id,
976
+ "error_detail": error_detail,
977
+ },
978
+ remediation=remediation
979
+ or "Try an earlier backup or restore from version control.",
980
+ request_id=request_id,
981
+ )
982
+
983
+
984
+ def rollback_failed_error(
985
+ spec_id: str,
986
+ backup_id: str,
987
+ error_detail: str,
988
+ *,
989
+ remediation: Optional[str] = None,
990
+ request_id: Optional[str] = None,
991
+ ) -> ToolResponse:
992
+ """Create an error response for failed rollback operation.
993
+
994
+ Use when a rollback operation fails after starting.
995
+
996
+ Args:
997
+ spec_id: The spec being rolled back.
998
+ backup_id: The backup being restored from.
999
+ error_detail: What went wrong during rollback.
1000
+ remediation: Guidance on how to recover.
1001
+ request_id: Correlation identifier.
1002
+
1003
+ Example:
1004
+ >>> rollback_failed_error("my-spec", "backup-001", "Write permission denied")
1005
+ """
1006
+ return error_response(
1007
+ f"Rollback failed for spec '{spec_id}' from backup '{backup_id}': {error_detail}",
1008
+ error_code=ErrorCode.ROLLBACK_FAILED,
1009
+ error_type=ErrorType.INTERNAL,
1010
+ data={
1011
+ "spec_id": spec_id,
1012
+ "backup_id": backup_id,
1013
+ "error_detail": error_detail,
1014
+ },
1015
+ remediation=remediation
1016
+ or "Check file permissions. A safety backup was created before rollback attempt.",
1017
+ request_id=request_id,
1018
+ )
1019
+
1020
+
1021
+ def comparison_failed_error(
1022
+ source: str,
1023
+ target: str,
1024
+ error_detail: str,
1025
+ *,
1026
+ remediation: Optional[str] = None,
1027
+ request_id: Optional[str] = None,
1028
+ ) -> ToolResponse:
1029
+ """Create an error response for failed diff/comparison operation.
1030
+
1031
+ Use when a spec comparison operation fails.
1032
+
1033
+ Args:
1034
+ source: The source spec or backup being compared.
1035
+ target: The target spec or backup being compared.
1036
+ error_detail: What went wrong during comparison.
1037
+ remediation: Guidance on how to resolve.
1038
+ request_id: Correlation identifier.
1039
+
1040
+ Example:
1041
+ >>> comparison_failed_error("my-spec-v1", "my-spec-v2", "Schema version mismatch")
1042
+ """
1043
+ return error_response(
1044
+ f"Comparison failed between '{source}' and '{target}': {error_detail}",
1045
+ error_code=ErrorCode.COMPARISON_FAILED,
1046
+ error_type=ErrorType.INTERNAL,
1047
+ data={
1048
+ "source": source,
1049
+ "target": target,
1050
+ "error_detail": error_detail,
1051
+ },
1052
+ remediation=remediation
1053
+ or "Ensure both specs are valid and use compatible schema versions.",
1054
+ request_id=request_id,
1055
+ )
1056
+
1057
+
1058
+ # ---------------------------------------------------------------------------
1059
+ # AI/LLM Provider Error Helpers
1060
+ # ---------------------------------------------------------------------------
1061
+
1062
+
1063
+ def ai_no_provider_error(
1064
+ message: str = "No AI provider available",
1065
+ *,
1066
+ required_providers: Optional[Sequence[str]] = None,
1067
+ remediation: Optional[str] = None,
1068
+ request_id: Optional[str] = None,
1069
+ ) -> ToolResponse:
1070
+ """Create an error response for when no AI provider is available.
1071
+
1072
+ Use when an AI consultation is requested but no providers are configured
1073
+ or all configured providers have failed availability checks.
1074
+
1075
+ Args:
1076
+ message: Human-readable description.
1077
+ required_providers: List of provider IDs that were checked.
1078
+ remediation: Guidance on how to configure a provider.
1079
+ request_id: Correlation identifier.
1080
+
1081
+ Example:
1082
+ >>> ai_no_provider_error(
1083
+ ... "No AI provider available for plan review",
1084
+ ... required_providers=["gemini", "cursor-agent", "codex"],
1085
+ ... )
1086
+ """
1087
+ data: Dict[str, Any] = {}
1088
+ if required_providers:
1089
+ data["required_providers"] = list(required_providers)
1090
+
1091
+ default_remediation = (
1092
+ "Configure an AI provider: set GEMINI_API_KEY, OPENAI_API_KEY, "
1093
+ "or ANTHROPIC_API_KEY environment variable, or ensure cursor-agent is available."
1094
+ )
1095
+
1096
+ return error_response(
1097
+ message,
1098
+ error_code=ErrorCode.AI_NO_PROVIDER,
1099
+ error_type=ErrorType.AI_PROVIDER,
1100
+ data=data if data else None,
1101
+ remediation=remediation or default_remediation,
1102
+ request_id=request_id,
1103
+ )
1104
+
1105
+
1106
+ def ai_provider_timeout_error(
1107
+ provider_id: str,
1108
+ timeout_seconds: int,
1109
+ *,
1110
+ message: Optional[str] = None,
1111
+ remediation: Optional[str] = None,
1112
+ request_id: Optional[str] = None,
1113
+ ) -> ToolResponse:
1114
+ """Create an error response for AI provider execution timeout.
1115
+
1116
+ Use when an AI provider call exceeds the configured timeout limit.
1117
+
1118
+ Args:
1119
+ provider_id: The provider that timed out (e.g., "gemini", "codex").
1120
+ timeout_seconds: The timeout that was exceeded.
1121
+ message: Human-readable description (auto-generated if not provided).
1122
+ remediation: Guidance on how to handle the timeout.
1123
+ request_id: Correlation identifier.
1124
+
1125
+ Example:
1126
+ >>> ai_provider_timeout_error("gemini", 300)
1127
+ """
1128
+ default_message = f"AI provider '{provider_id}' timed out after {timeout_seconds}s"
1129
+
1130
+ return error_response(
1131
+ message or default_message,
1132
+ error_code=ErrorCode.AI_PROVIDER_TIMEOUT,
1133
+ error_type=ErrorType.AI_PROVIDER,
1134
+ data={
1135
+ "provider_id": provider_id,
1136
+ "timeout_seconds": timeout_seconds,
1137
+ },
1138
+ remediation=remediation
1139
+ or (
1140
+ "Try again with a smaller context, increase the timeout, "
1141
+ "or use a different provider."
1142
+ ),
1143
+ request_id=request_id,
1144
+ )
1145
+
1146
+
1147
+ def ai_provider_error(
1148
+ provider_id: str,
1149
+ error_detail: str,
1150
+ *,
1151
+ status_code: Optional[int] = None,
1152
+ remediation: Optional[str] = None,
1153
+ request_id: Optional[str] = None,
1154
+ ) -> ToolResponse:
1155
+ """Create an error response for when an AI provider returns an error.
1156
+
1157
+ Use when an AI provider API call fails with an error response.
1158
+
1159
+ Args:
1160
+ provider_id: The provider that returned the error.
1161
+ error_detail: The error message from the provider.
1162
+ status_code: HTTP status code from the provider (if applicable).
1163
+ remediation: Guidance on how to resolve the issue.
1164
+ request_id: Correlation identifier.
1165
+
1166
+ Example:
1167
+ >>> ai_provider_error("gemini", "Invalid API key", status_code=401)
1168
+ """
1169
+ data: Dict[str, Any] = {
1170
+ "provider_id": provider_id,
1171
+ "error_detail": error_detail,
1172
+ }
1173
+ if status_code is not None:
1174
+ data["status_code"] = status_code
1175
+
1176
+ return error_response(
1177
+ f"AI provider '{provider_id}' returned error: {error_detail}",
1178
+ error_code=ErrorCode.AI_PROVIDER_ERROR,
1179
+ error_type=ErrorType.AI_PROVIDER,
1180
+ data=data,
1181
+ remediation=remediation
1182
+ or (
1183
+ "Check provider configuration and API key validity. "
1184
+ "Consult provider documentation for error details."
1185
+ ),
1186
+ request_id=request_id,
1187
+ )
1188
+
1189
+
1190
+ def ai_context_too_large_error(
1191
+ context_tokens: int,
1192
+ max_tokens: int,
1193
+ *,
1194
+ provider_id: Optional[str] = None,
1195
+ remediation: Optional[str] = None,
1196
+ request_id: Optional[str] = None,
1197
+ ) -> ToolResponse:
1198
+ """Create an error response for when context exceeds model limits.
1199
+
1200
+ Use when the prompt/context size exceeds the AI model's token limit.
1201
+
1202
+ Args:
1203
+ context_tokens: Number of tokens in the attempted context.
1204
+ max_tokens: Maximum tokens allowed by the model.
1205
+ provider_id: The provider that rejected the context.
1206
+ remediation: Guidance on how to reduce context size.
1207
+ request_id: Correlation identifier.
1208
+
1209
+ Example:
1210
+ >>> ai_context_too_large_error(150000, 128000, provider_id="gemini")
1211
+ """
1212
+ data: Dict[str, Any] = {
1213
+ "context_tokens": context_tokens,
1214
+ "max_tokens": max_tokens,
1215
+ "overflow_tokens": context_tokens - max_tokens,
1216
+ }
1217
+ if provider_id:
1218
+ data["provider_id"] = provider_id
1219
+
1220
+ return error_response(
1221
+ f"Context size ({context_tokens} tokens) exceeds limit ({max_tokens} tokens)",
1222
+ error_code=ErrorCode.AI_CONTEXT_TOO_LARGE,
1223
+ error_type=ErrorType.AI_PROVIDER,
1224
+ data=data,
1225
+ remediation=remediation
1226
+ or (
1227
+ "Reduce context size by: excluding large files, using incremental mode, "
1228
+ "or reviewing only specific tasks/phases instead of the full spec."
1229
+ ),
1230
+ request_id=request_id,
1231
+ )
1232
+
1233
+
1234
+ def ai_prompt_not_found_error(
1235
+ prompt_id: str,
1236
+ *,
1237
+ available_prompts: Optional[Sequence[str]] = None,
1238
+ workflow: Optional[str] = None,
1239
+ remediation: Optional[str] = None,
1240
+ request_id: Optional[str] = None,
1241
+ ) -> ToolResponse:
1242
+ """Create an error response for when a prompt template is not found.
1243
+
1244
+ Use when a requested prompt ID doesn't exist in the prompt registry.
1245
+
1246
+ Args:
1247
+ prompt_id: The prompt ID that was not found.
1248
+ available_prompts: List of valid prompt IDs for the workflow.
1249
+ workflow: The workflow context (e.g., "plan_review", "fidelity_review").
1250
+ remediation: Guidance on how to find the correct prompt ID.
1251
+ request_id: Correlation identifier.
1252
+
1253
+ Example:
1254
+ >>> ai_prompt_not_found_error(
1255
+ ... "INVALID_PROMPT",
1256
+ ... available_prompts=["PLAN_REVIEW_FULL_V1", "PLAN_REVIEW_QUICK_V1"],
1257
+ ... workflow="plan_review",
1258
+ ... )
1259
+ """
1260
+ data: Dict[str, Any] = {"prompt_id": prompt_id}
1261
+ if available_prompts:
1262
+ data["available_prompts"] = list(available_prompts)
1263
+ if workflow:
1264
+ data["workflow"] = workflow
1265
+
1266
+ available_str = ""
1267
+ if available_prompts:
1268
+ available_str = f" Available: {', '.join(available_prompts[:5])}"
1269
+ if len(available_prompts) > 5:
1270
+ available_str += f" (and {len(available_prompts) - 5} more)"
1271
+
1272
+ return error_response(
1273
+ f"Prompt '{prompt_id}' not found.{available_str}",
1274
+ error_code=ErrorCode.AI_PROMPT_NOT_FOUND,
1275
+ error_type=ErrorType.NOT_FOUND,
1276
+ data=data,
1277
+ remediation=remediation
1278
+ or (
1279
+ "Use a valid prompt ID from the workflow's prompt builder. "
1280
+ "Call list_prompts() to see available templates."
1281
+ ),
1282
+ request_id=request_id,
1283
+ )
1284
+
1285
+
1286
+ def ai_cache_stale_error(
1287
+ cache_key: str,
1288
+ cache_age_seconds: int,
1289
+ max_age_seconds: int,
1290
+ *,
1291
+ remediation: Optional[str] = None,
1292
+ request_id: Optional[str] = None,
1293
+ ) -> ToolResponse:
1294
+ """Create an error response for when cached AI result is stale.
1295
+
1296
+ Use when a cached consultation result has expired and needs refresh.
1297
+
1298
+ Args:
1299
+ cache_key: Identifier for the cached item.
1300
+ cache_age_seconds: Age of the cached result in seconds.
1301
+ max_age_seconds: Maximum allowed age for cached results.
1302
+ remediation: Guidance on how to refresh the cache.
1303
+ request_id: Correlation identifier.
1304
+
1305
+ Example:
1306
+ >>> ai_cache_stale_error(
1307
+ ... "plan_review:spec-001:full",
1308
+ ... cache_age_seconds=7200,
1309
+ ... max_age_seconds=3600,
1310
+ ... )
1311
+ """
1312
+ return error_response(
1313
+ f"Cached result for '{cache_key}' is stale ({cache_age_seconds}s > {max_age_seconds}s)",
1314
+ error_code=ErrorCode.AI_CACHE_STALE,
1315
+ error_type=ErrorType.AI_PROVIDER,
1316
+ data={
1317
+ "cache_key": cache_key,
1318
+ "cache_age_seconds": cache_age_seconds,
1319
+ "max_age_seconds": max_age_seconds,
1320
+ },
1321
+ remediation=remediation
1322
+ or (
1323
+ "Re-run the consultation to refresh cached results, "
1324
+ "or use --no-cache to bypass the cache."
1325
+ ),
1326
+ request_id=request_id,
1327
+ )
1328
+
1329
+
1330
+ # ---------------------------------------------------------------------------
1331
+ # Error Message Sanitization
1332
+ # ---------------------------------------------------------------------------
1333
+
1334
+
1335
+ try:
1336
+ from pydantic import BaseModel, Field
1337
+ PYDANTIC_AVAILABLE = True
1338
+ except ImportError:
1339
+ PYDANTIC_AVAILABLE = False
1340
+
1341
+
1342
+ def sanitize_error_message(
1343
+ exc: Exception,
1344
+ context: str = "",
1345
+ include_type: bool = False,
1346
+ ) -> str:
1347
+ """
1348
+ Convert exception to user-safe message without internal details.
1349
+
1350
+ Logs full exception server-side for debugging per MCP best practices:
1351
+ - "Never expose internal details" (07-error-semantics.md:16)
1352
+ - "Log full details server-side" (07-error-semantics.md:23)
1353
+
1354
+ Args:
1355
+ exc: The exception to sanitize
1356
+ context: Optional context for logging (e.g., "spec validation")
1357
+ include_type: Whether to include exception type name in message
1358
+
1359
+ Returns:
1360
+ User-safe error message without file paths, stack traces, or internal state
1361
+ """
1362
+ # Log full details server-side for debugging
1363
+ if context:
1364
+ logger.debug(f"Error in {context}: {exc}", exc_info=True)
1365
+ else:
1366
+ logger.debug(f"Error: {exc}", exc_info=True)
1367
+
1368
+ # Map known exception types to safe messages
1369
+ type_name = type(exc).__name__
1370
+
1371
+ if isinstance(exc, FileNotFoundError):
1372
+ return "Required file or resource not found"
1373
+ if isinstance(exc, json.JSONDecodeError):
1374
+ return "Invalid JSON format"
1375
+ if isinstance(exc, subprocess.TimeoutExpired):
1376
+ timeout = getattr(exc, "timeout", "unknown")
1377
+ return f"Operation timed out after {timeout} seconds"
1378
+ if isinstance(exc, PermissionError):
1379
+ return "Permission denied for requested operation"
1380
+ if isinstance(exc, ValueError):
1381
+ suffix = f" ({type_name})" if include_type else ""
1382
+ return f"Invalid value provided{suffix}"
1383
+ if isinstance(exc, KeyError):
1384
+ return "Required configuration key not found"
1385
+ if isinstance(exc, ConnectionError):
1386
+ return "Connection failed - service may be unavailable"
1387
+ if isinstance(exc, OSError):
1388
+ return "System I/O error occurred"
1389
+
1390
+ # Generic fallback - don't expose exception message
1391
+ suffix = f" ({type_name})" if include_type else ""
1392
+ return f"An internal error occurred{suffix}"
1393
+
1394
+
1395
+ # ---------------------------------------------------------------------------
1396
+ # Batch Operation Response Schemas (Pydantic)
1397
+ # ---------------------------------------------------------------------------
1398
+ # These schemas provide type-safe definitions for batch operation responses.
1399
+ # They ensure contract stability and enable validation of batch operation data.
1400
+
1401
+
1402
+ if PYDANTIC_AVAILABLE:
1403
+
1404
+ class DependencyNode(BaseModel):
1405
+ """A node in the dependency graph representing a task."""
1406
+
1407
+ id: str = Field(..., description="Task identifier")
1408
+ title: str = Field(default="", description="Task title")
1409
+ status: str = Field(default="", description="Task status")
1410
+ file_path: Optional[str] = Field(
1411
+ default=None, description="File path associated with the task"
1412
+ )
1413
+ is_target: bool = Field(
1414
+ default=False, description="Whether this is a target task in the batch"
1415
+ )
1416
+
1417
+ class DependencyEdge(BaseModel):
1418
+ """An edge in the dependency graph representing a dependency relationship."""
1419
+
1420
+ from_id: str = Field(..., alias="from", description="Source task ID")
1421
+ to_id: str = Field(..., alias="to", description="Target task ID")
1422
+ edge_type: str = Field(
1423
+ default="blocks", alias="type", description="Type of dependency (blocks)"
1424
+ )
1425
+
1426
+ model_config = {"populate_by_name": True}
1427
+
1428
+ class DependencyGraph(BaseModel):
1429
+ """Dependency graph structure for batch tasks.
1430
+
1431
+ Contains nodes (tasks) and edges (dependency relationships) to visualize
1432
+ task dependencies for parallel execution planning.
1433
+ """
1434
+
1435
+ nodes: list[DependencyNode] = Field(
1436
+ default_factory=list, description="Task nodes in the graph"
1437
+ )
1438
+ edges: list[DependencyEdge] = Field(
1439
+ default_factory=list, description="Dependency edges between tasks"
1440
+ )
1441
+
1442
+ class BatchTaskDependencies(BaseModel):
1443
+ """Dependency status for a task in a batch."""
1444
+
1445
+ task_id: str = Field(..., description="Task identifier")
1446
+ can_start: bool = Field(
1447
+ default=True, description="Whether the task can be started"
1448
+ )
1449
+ blocked_by: list[str] = Field(
1450
+ default_factory=list, description="IDs of tasks blocking this one"
1451
+ )
1452
+ soft_depends: list[str] = Field(
1453
+ default_factory=list, description="IDs of soft dependencies"
1454
+ )
1455
+ blocks: list[str] = Field(
1456
+ default_factory=list, description="IDs of tasks this one blocks"
1457
+ )
1458
+
1459
+ class BatchTaskContext(BaseModel):
1460
+ """Context for a single task in a batch prepare response.
1461
+
1462
+ Contains all information needed to execute a task in parallel with others.
1463
+ """
1464
+
1465
+ task_id: str = Field(..., description="Unique task identifier")
1466
+ title: str = Field(default="", description="Task title")
1467
+ task_type: str = Field(
1468
+ default="task", alias="type", description="Task type (task, subtask, verify)"
1469
+ )
1470
+ status: str = Field(default="pending", description="Current task status")
1471
+ metadata: Dict[str, Any] = Field(
1472
+ default_factory=dict,
1473
+ description="Task metadata including file_path, description, etc.",
1474
+ )
1475
+ dependencies: Optional[BatchTaskDependencies] = Field(
1476
+ default=None, description="Dependency status for the task"
1477
+ )
1478
+ phase: Optional[Dict[str, Any]] = Field(
1479
+ default=None, description="Phase context (id, title, progress)"
1480
+ )
1481
+ parent: Optional[Dict[str, Any]] = Field(
1482
+ default=None, description="Parent task context (id, title, position_label)"
1483
+ )
1484
+
1485
+ model_config = {"populate_by_name": True}
1486
+
1487
+ class StaleTaskInfo(BaseModel):
1488
+ """Information about a stale in_progress task."""
1489
+
1490
+ task_id: str = Field(..., description="Task identifier")
1491
+ title: str = Field(default="", description="Task title")
1492
+
1493
+ class BatchPrepareResponse(BaseModel):
1494
+ """Response schema for prepare_batch_context operation.
1495
+
1496
+ Contains independent tasks that can be executed in parallel along with
1497
+ context, dependency information, and warnings.
1498
+ """
1499
+
1500
+ tasks: list[BatchTaskContext] = Field(
1501
+ default_factory=list, description="Tasks ready for parallel execution"
1502
+ )
1503
+ task_count: int = Field(default=0, description="Number of tasks in the batch")
1504
+ spec_complete: bool = Field(
1505
+ default=False, description="Whether the spec has no remaining tasks"
1506
+ )
1507
+ all_blocked: bool = Field(
1508
+ default=False, description="Whether all remaining tasks are blocked"
1509
+ )
1510
+ warnings: list[str] = Field(
1511
+ default_factory=list, description="Non-fatal warnings about the batch"
1512
+ )
1513
+ stale_tasks: list[StaleTaskInfo] = Field(
1514
+ default_factory=list, description="In-progress tasks exceeding time threshold"
1515
+ )
1516
+ dependency_graph: DependencyGraph = Field(
1517
+ default_factory=DependencyGraph,
1518
+ description="Dependency graph for batch tasks",
1519
+ )
1520
+ token_estimate: Optional[int] = Field(
1521
+ default=None, description="Estimated token count for the batch context"
1522
+ )
1523
+
1524
+ class BatchStartResponse(BaseModel):
1525
+ """Response schema for start_batch operation.
1526
+
1527
+ Confirms which tasks were atomically started and when.
1528
+ """
1529
+
1530
+ started: list[str] = Field(
1531
+ default_factory=list, description="IDs of tasks successfully started"
1532
+ )
1533
+ started_count: int = Field(
1534
+ default=0, description="Number of tasks started"
1535
+ )
1536
+ started_at: Optional[str] = Field(
1537
+ default=None, description="ISO timestamp when tasks were started"
1538
+ )
1539
+ errors: Optional[list[str]] = Field(
1540
+ default=None, description="Validation errors if operation failed"
1541
+ )
1542
+
1543
+ class BatchTaskCompletion(BaseModel):
1544
+ """Input schema for a single task completion in complete_batch.
1545
+
1546
+ Used to specify outcome for each task being completed.
1547
+ """
1548
+
1549
+ task_id: str = Field(..., description="Task identifier to complete")
1550
+ success: bool = Field(
1551
+ ..., description="True if task succeeded, False if failed"
1552
+ )
1553
+ completion_note: str = Field(
1554
+ default="", description="Note describing what was accomplished or why it failed"
1555
+ )
1556
+
1557
+ class BatchTaskResult(BaseModel):
1558
+ """Result for a single task in the complete_batch response."""
1559
+
1560
+ status: str = Field(
1561
+ ..., description="Result status: completed, failed, skipped, error"
1562
+ )
1563
+ completed_at: Optional[str] = Field(
1564
+ default=None, description="ISO timestamp when completed (if successful)"
1565
+ )
1566
+ failed_at: Optional[str] = Field(
1567
+ default=None, description="ISO timestamp when failed (if unsuccessful)"
1568
+ )
1569
+ retry_count: Optional[int] = Field(
1570
+ default=None, description="Updated retry count (if failed)"
1571
+ )
1572
+ error: Optional[str] = Field(
1573
+ default=None, description="Error message (if status is error or skipped)"
1574
+ )
1575
+
1576
+ class BatchCompleteResponse(BaseModel):
1577
+ """Response schema for complete_batch operation.
1578
+
1579
+ Contains per-task results and summary counts for the batch completion.
1580
+ """
1581
+
1582
+ results: Dict[str, BatchTaskResult] = Field(
1583
+ default_factory=dict,
1584
+ description="Per-task results keyed by task_id",
1585
+ )
1586
+ completed_count: int = Field(
1587
+ default=0, description="Number of tasks successfully completed"
1588
+ )
1589
+ failed_count: int = Field(
1590
+ default=0, description="Number of tasks that failed"
1591
+ )
1592
+ total_processed: int = Field(
1593
+ default=0, description="Total number of completions processed"
1594
+ )
1595
+
1596
+ # Export Pydantic models
1597
+ __all_pydantic__ = [
1598
+ "DependencyNode",
1599
+ "DependencyEdge",
1600
+ "DependencyGraph",
1601
+ "BatchTaskDependencies",
1602
+ "BatchTaskContext",
1603
+ "StaleTaskInfo",
1604
+ "BatchPrepareResponse",
1605
+ "BatchStartResponse",
1606
+ "BatchTaskCompletion",
1607
+ "BatchTaskResult",
1608
+ "BatchCompleteResponse",
1609
+ ]
1610
+
1611
+ else:
1612
+ # Pydantic not available - provide None placeholders
1613
+ DependencyNode = None # type: ignore[misc,assignment]
1614
+ DependencyEdge = None # type: ignore[misc,assignment]
1615
+ DependencyGraph = None # type: ignore[misc,assignment]
1616
+ BatchTaskDependencies = None # type: ignore[misc,assignment]
1617
+ BatchTaskContext = None # type: ignore[misc,assignment]
1618
+ StaleTaskInfo = None # type: ignore[misc,assignment]
1619
+ BatchPrepareResponse = None # type: ignore[misc,assignment]
1620
+ BatchStartResponse = None # type: ignore[misc,assignment]
1621
+ BatchTaskCompletion = None # type: ignore[misc,assignment]
1622
+ BatchTaskResult = None # type: ignore[misc,assignment]
1623
+ BatchCompleteResponse = None # type: ignore[misc,assignment]
1624
+ __all_pydantic__ = []