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,479 @@
|
|
|
1
|
+
"""Unified error introspection tool with action routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from typing import Any, Dict, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
|
|
11
|
+
from foundry_mcp.config import ServerConfig
|
|
12
|
+
from foundry_mcp.core.naming import canonical_tool
|
|
13
|
+
from foundry_mcp.core.pagination import (
|
|
14
|
+
CursorError,
|
|
15
|
+
decode_cursor,
|
|
16
|
+
encode_cursor,
|
|
17
|
+
normalize_page_size,
|
|
18
|
+
paginated_response,
|
|
19
|
+
)
|
|
20
|
+
from foundry_mcp.core.responses import (
|
|
21
|
+
ErrorCode,
|
|
22
|
+
ErrorType,
|
|
23
|
+
error_response,
|
|
24
|
+
success_response,
|
|
25
|
+
)
|
|
26
|
+
from foundry_mcp.tools.unified.router import (
|
|
27
|
+
ActionDefinition,
|
|
28
|
+
ActionRouter,
|
|
29
|
+
ActionRouterError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_ACTION_SUMMARY = {
|
|
35
|
+
"list": "Query collected errors with filters + pagination",
|
|
36
|
+
"get": "Retrieve a single error record by identifier",
|
|
37
|
+
"stats": "Aggregate error counts across dimensions",
|
|
38
|
+
"patterns": "List recurring error fingerprints",
|
|
39
|
+
"cleanup": "Apply retention limits to error storage",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _error_collection_disabled_response() -> dict:
|
|
44
|
+
return asdict(
|
|
45
|
+
error_response(
|
|
46
|
+
"Error collection is disabled",
|
|
47
|
+
error_code=ErrorCode.UNAVAILABLE,
|
|
48
|
+
error_type=ErrorType.UNAVAILABLE,
|
|
49
|
+
details={"config_key": "error_collection.enabled"},
|
|
50
|
+
remediation="Set error_collection.enabled=true in server configuration",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _collector_unavailable_response() -> dict:
|
|
56
|
+
return asdict(
|
|
57
|
+
error_response(
|
|
58
|
+
"Error collector is not enabled",
|
|
59
|
+
error_code=ErrorCode.UNAVAILABLE,
|
|
60
|
+
error_type=ErrorType.UNAVAILABLE,
|
|
61
|
+
remediation="Initialize the error collector before querying records",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _invalid_cursor_response(exc: CursorError) -> dict:
|
|
67
|
+
return asdict(
|
|
68
|
+
error_response(
|
|
69
|
+
f"Invalid cursor: {exc}",
|
|
70
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
71
|
+
error_type=ErrorType.VALIDATION,
|
|
72
|
+
remediation="Pass a cursor value returned by a previous response",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _missing_parameter_response(param: str, action: str) -> dict:
|
|
78
|
+
return asdict(
|
|
79
|
+
error_response(
|
|
80
|
+
f"Missing required parameter '{param}' for error.{action}",
|
|
81
|
+
error_code=ErrorCode.MISSING_REQUIRED,
|
|
82
|
+
error_type=ErrorType.VALIDATION,
|
|
83
|
+
remediation=f"Provide '{param}' when action={action}",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_error_store(
|
|
89
|
+
config: ServerConfig,
|
|
90
|
+
) -> Tuple[Any | None, Optional[dict]]:
|
|
91
|
+
if (
|
|
92
|
+
not getattr(config, "error_collection", None)
|
|
93
|
+
or not config.error_collection.enabled
|
|
94
|
+
):
|
|
95
|
+
return None, _error_collection_disabled_response()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
from foundry_mcp.core.error_collection import get_error_collector
|
|
99
|
+
|
|
100
|
+
collector = get_error_collector()
|
|
101
|
+
except Exception as exc: # pragma: no cover - defensive import guard
|
|
102
|
+
logger.exception("Failed to initialize error collector")
|
|
103
|
+
return None, asdict(
|
|
104
|
+
error_response(
|
|
105
|
+
f"Failed to initialize error collector: {exc}",
|
|
106
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
107
|
+
error_type=ErrorType.INTERNAL,
|
|
108
|
+
remediation="Inspect server logs for error collection issues",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not collector.is_enabled():
|
|
113
|
+
return None, _collector_unavailable_response()
|
|
114
|
+
|
|
115
|
+
return collector.store, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def perform_error_list(
|
|
119
|
+
*,
|
|
120
|
+
config: ServerConfig,
|
|
121
|
+
tool_name: Optional[str] = None,
|
|
122
|
+
error_code: Optional[str] = None,
|
|
123
|
+
error_type: Optional[str] = None,
|
|
124
|
+
fingerprint: Optional[str] = None,
|
|
125
|
+
provider_id: Optional[str] = None,
|
|
126
|
+
since: Optional[str] = None,
|
|
127
|
+
until: Optional[str] = None,
|
|
128
|
+
limit: Optional[int] = None,
|
|
129
|
+
cursor: Optional[str] = None,
|
|
130
|
+
) -> dict:
|
|
131
|
+
store, error = _resolve_error_store(config)
|
|
132
|
+
if error:
|
|
133
|
+
return error
|
|
134
|
+
assert store is not None
|
|
135
|
+
|
|
136
|
+
page_size = normalize_page_size(limit)
|
|
137
|
+
offset = 0
|
|
138
|
+
if cursor:
|
|
139
|
+
try:
|
|
140
|
+
cursor_data = decode_cursor(cursor)
|
|
141
|
+
offset = cursor_data.get("offset", 0)
|
|
142
|
+
except CursorError as exc:
|
|
143
|
+
return _invalid_cursor_response(exc)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
records = store.query(
|
|
147
|
+
tool_name=tool_name,
|
|
148
|
+
error_code=error_code,
|
|
149
|
+
error_type=error_type,
|
|
150
|
+
fingerprint=fingerprint,
|
|
151
|
+
provider_id=provider_id,
|
|
152
|
+
since=since,
|
|
153
|
+
until=until,
|
|
154
|
+
limit=page_size + 1,
|
|
155
|
+
offset=offset,
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc: # pragma: no cover - backend failure guard
|
|
158
|
+
logger.exception("Error querying errors")
|
|
159
|
+
return asdict(
|
|
160
|
+
error_response(
|
|
161
|
+
f"Failed to query errors: {exc}",
|
|
162
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
163
|
+
error_type=ErrorType.INTERNAL,
|
|
164
|
+
remediation="Check error collector logs",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
has_more = len(records) > page_size
|
|
169
|
+
visible_records = records[:page_size] if has_more else records
|
|
170
|
+
next_cursor = encode_cursor({"offset": offset + page_size}) if has_more else None
|
|
171
|
+
error_dicts = [record.to_dict() for record in visible_records]
|
|
172
|
+
|
|
173
|
+
data = {
|
|
174
|
+
"errors": error_dicts,
|
|
175
|
+
"count": len(error_dicts),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return paginated_response(
|
|
179
|
+
data=data,
|
|
180
|
+
cursor=next_cursor,
|
|
181
|
+
has_more=has_more,
|
|
182
|
+
page_size=page_size,
|
|
183
|
+
total_count=store.count(),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def perform_error_get(*, config: ServerConfig, error_id: Optional[str] = None) -> dict:
|
|
188
|
+
if not error_id:
|
|
189
|
+
return _missing_parameter_response("error_id", "get")
|
|
190
|
+
|
|
191
|
+
store, error = _resolve_error_store(config)
|
|
192
|
+
if error:
|
|
193
|
+
return error
|
|
194
|
+
assert store is not None
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
record = store.get(error_id)
|
|
198
|
+
except Exception as exc: # pragma: no cover - backend failure guard
|
|
199
|
+
logger.exception("Error retrieving error record")
|
|
200
|
+
return asdict(
|
|
201
|
+
error_response(
|
|
202
|
+
f"Failed to retrieve error: {exc}",
|
|
203
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
204
|
+
error_type=ErrorType.INTERNAL,
|
|
205
|
+
remediation="Check error collector logs",
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if record is None:
|
|
210
|
+
return asdict(
|
|
211
|
+
error_response(
|
|
212
|
+
f"Error record not found: {error_id}",
|
|
213
|
+
error_code=ErrorCode.NOT_FOUND,
|
|
214
|
+
error_type=ErrorType.NOT_FOUND,
|
|
215
|
+
remediation="Verify the error ID via error.list",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return asdict(success_response(data={"error": record.to_dict()}))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def perform_error_stats(*, config: ServerConfig) -> dict:
|
|
223
|
+
store, error = _resolve_error_store(config)
|
|
224
|
+
if error:
|
|
225
|
+
return error
|
|
226
|
+
assert store is not None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
stats = store.get_stats()
|
|
230
|
+
except Exception as exc: # pragma: no cover - backend failure guard
|
|
231
|
+
logger.exception("Error retrieving error stats")
|
|
232
|
+
return asdict(
|
|
233
|
+
error_response(
|
|
234
|
+
f"Failed to get error stats: {exc}",
|
|
235
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
236
|
+
error_type=ErrorType.INTERNAL,
|
|
237
|
+
remediation="Inspect error collector logs",
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return asdict(success_response(data=stats))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def perform_error_patterns(*, config: ServerConfig, min_count: int = 3) -> dict:
|
|
245
|
+
store, error = _resolve_error_store(config)
|
|
246
|
+
if error:
|
|
247
|
+
return error
|
|
248
|
+
assert store is not None
|
|
249
|
+
|
|
250
|
+
effective_min = max(1, min_count or 1)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
patterns = store.get_patterns(min_count=effective_min)
|
|
254
|
+
|
|
255
|
+
except Exception as exc: # pragma: no cover - backend failure guard
|
|
256
|
+
logger.exception("Error retrieving error patterns")
|
|
257
|
+
return asdict(
|
|
258
|
+
error_response(
|
|
259
|
+
f"Failed to get error patterns: {exc}",
|
|
260
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
261
|
+
error_type=ErrorType.INTERNAL,
|
|
262
|
+
remediation="Inspect error collector logs",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return asdict(
|
|
267
|
+
success_response(
|
|
268
|
+
data={
|
|
269
|
+
"patterns": patterns,
|
|
270
|
+
"pattern_count": len(patterns),
|
|
271
|
+
"min_count_filter": effective_min,
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def perform_error_cleanup(
|
|
278
|
+
*,
|
|
279
|
+
config: ServerConfig,
|
|
280
|
+
retention_days: Optional[int] = None,
|
|
281
|
+
max_errors: Optional[int] = None,
|
|
282
|
+
dry_run: bool = False,
|
|
283
|
+
) -> dict:
|
|
284
|
+
store, error = _resolve_error_store(config)
|
|
285
|
+
if error:
|
|
286
|
+
return error
|
|
287
|
+
assert store is not None
|
|
288
|
+
|
|
289
|
+
effective_retention = retention_days or config.error_collection.retention_days
|
|
290
|
+
effective_max = max_errors or config.error_collection.max_errors
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
if dry_run:
|
|
294
|
+
current_count = store.count()
|
|
295
|
+
return asdict(
|
|
296
|
+
success_response(
|
|
297
|
+
data={
|
|
298
|
+
"current_count": current_count,
|
|
299
|
+
"retention_days": effective_retention,
|
|
300
|
+
"max_errors": effective_max,
|
|
301
|
+
"dry_run": True,
|
|
302
|
+
"message": "Dry run - no records deleted",
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
deleted_count = store.cleanup(
|
|
308
|
+
retention_days=effective_retention,
|
|
309
|
+
max_errors=effective_max,
|
|
310
|
+
)
|
|
311
|
+
except Exception as exc: # pragma: no cover - backend failure guard
|
|
312
|
+
logger.exception("Error cleaning up error records")
|
|
313
|
+
return asdict(
|
|
314
|
+
error_response(
|
|
315
|
+
f"Failed to cleanup errors: {exc}",
|
|
316
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
317
|
+
error_type=ErrorType.INTERNAL,
|
|
318
|
+
remediation="Inspect error collector logs",
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return asdict(
|
|
323
|
+
success_response(
|
|
324
|
+
data={
|
|
325
|
+
"deleted_count": deleted_count,
|
|
326
|
+
"retention_days": effective_retention,
|
|
327
|
+
"max_errors": effective_max,
|
|
328
|
+
"dry_run": False,
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _handle_error_list(*, config: ServerConfig, **payload: Any) -> dict:
|
|
335
|
+
# Filter out parameters not accepted by perform_error_list
|
|
336
|
+
filtered_payload = {
|
|
337
|
+
k: v
|
|
338
|
+
for k, v in payload.items()
|
|
339
|
+
if k
|
|
340
|
+
in (
|
|
341
|
+
"tool_name",
|
|
342
|
+
"error_code",
|
|
343
|
+
"error_type",
|
|
344
|
+
"fingerprint",
|
|
345
|
+
"provider_id",
|
|
346
|
+
"since",
|
|
347
|
+
"until",
|
|
348
|
+
"limit",
|
|
349
|
+
"cursor",
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
return perform_error_list(config=config, **filtered_payload)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _handle_error_get(*, config: ServerConfig, **payload: Any) -> dict:
|
|
356
|
+
return perform_error_get(config=config, error_id=payload.get("error_id"))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _handle_error_stats(*, config: ServerConfig, **_: Any) -> dict:
|
|
360
|
+
return perform_error_stats(config=config)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _handle_error_patterns(*, config: ServerConfig, **payload: Any) -> dict:
|
|
364
|
+
return perform_error_patterns(config=config, min_count=payload.get("min_count", 3))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _handle_error_cleanup(*, config: ServerConfig, **payload: Any) -> dict:
|
|
368
|
+
return perform_error_cleanup(
|
|
369
|
+
config=config,
|
|
370
|
+
retention_days=payload.get("retention_days"),
|
|
371
|
+
max_errors=payload.get("max_errors"),
|
|
372
|
+
dry_run=payload.get("dry_run", False),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
_ERROR_ROUTER = ActionRouter(
|
|
377
|
+
tool_name="error",
|
|
378
|
+
actions=[
|
|
379
|
+
ActionDefinition(
|
|
380
|
+
name="list",
|
|
381
|
+
handler=_handle_error_list,
|
|
382
|
+
summary=_ACTION_SUMMARY["list"],
|
|
383
|
+
),
|
|
384
|
+
ActionDefinition(
|
|
385
|
+
name="get",
|
|
386
|
+
handler=_handle_error_get,
|
|
387
|
+
summary=_ACTION_SUMMARY["get"],
|
|
388
|
+
),
|
|
389
|
+
ActionDefinition(
|
|
390
|
+
name="stats",
|
|
391
|
+
handler=_handle_error_stats,
|
|
392
|
+
summary=_ACTION_SUMMARY["stats"],
|
|
393
|
+
),
|
|
394
|
+
ActionDefinition(
|
|
395
|
+
name="patterns",
|
|
396
|
+
handler=_handle_error_patterns,
|
|
397
|
+
summary=_ACTION_SUMMARY["patterns"],
|
|
398
|
+
),
|
|
399
|
+
ActionDefinition(
|
|
400
|
+
name="cleanup",
|
|
401
|
+
handler=_handle_error_cleanup,
|
|
402
|
+
summary=_ACTION_SUMMARY["cleanup"],
|
|
403
|
+
),
|
|
404
|
+
],
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _dispatch_error_action(
|
|
409
|
+
*, action: str, payload: Dict[str, Any], config: ServerConfig
|
|
410
|
+
) -> dict:
|
|
411
|
+
try:
|
|
412
|
+
return _ERROR_ROUTER.dispatch(action=action, config=config, **payload)
|
|
413
|
+
except ActionRouterError as exc:
|
|
414
|
+
allowed = ", ".join(exc.allowed_actions)
|
|
415
|
+
return asdict(
|
|
416
|
+
error_response(
|
|
417
|
+
f"Unsupported error action '{action}'. Allowed actions: {allowed}",
|
|
418
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
419
|
+
error_type=ErrorType.VALIDATION,
|
|
420
|
+
remediation=f"Use one of: {allowed}",
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def register_unified_error_tool(mcp: FastMCP, config: ServerConfig) -> None:
|
|
426
|
+
"""Register the consolidated error tool."""
|
|
427
|
+
|
|
428
|
+
@canonical_tool(
|
|
429
|
+
mcp,
|
|
430
|
+
canonical_name="error",
|
|
431
|
+
)
|
|
432
|
+
def error(
|
|
433
|
+
action: str,
|
|
434
|
+
error_id: Optional[str] = None,
|
|
435
|
+
tool_name: Optional[str] = None,
|
|
436
|
+
error_code: Optional[str] = None,
|
|
437
|
+
error_type: Optional[str] = None,
|
|
438
|
+
fingerprint: Optional[str] = None,
|
|
439
|
+
provider_id: Optional[str] = None,
|
|
440
|
+
since: Optional[str] = None,
|
|
441
|
+
until: Optional[str] = None,
|
|
442
|
+
limit: Optional[int] = None,
|
|
443
|
+
cursor: Optional[str] = None,
|
|
444
|
+
min_count: int = 3,
|
|
445
|
+
retention_days: Optional[int] = None,
|
|
446
|
+
max_errors: Optional[int] = None,
|
|
447
|
+
dry_run: bool = False,
|
|
448
|
+
) -> dict:
|
|
449
|
+
"""Execute error workflows via the action router."""
|
|
450
|
+
|
|
451
|
+
payload = {
|
|
452
|
+
"error_id": error_id,
|
|
453
|
+
"tool_name": tool_name,
|
|
454
|
+
"error_code": error_code,
|
|
455
|
+
"error_type": error_type,
|
|
456
|
+
"fingerprint": fingerprint,
|
|
457
|
+
"provider_id": provider_id,
|
|
458
|
+
"since": since,
|
|
459
|
+
"until": until,
|
|
460
|
+
"limit": limit,
|
|
461
|
+
"cursor": cursor,
|
|
462
|
+
"min_count": min_count,
|
|
463
|
+
"retention_days": retention_days,
|
|
464
|
+
"max_errors": max_errors,
|
|
465
|
+
"dry_run": dry_run,
|
|
466
|
+
}
|
|
467
|
+
return _dispatch_error_action(action=action, payload=payload, config=config)
|
|
468
|
+
|
|
469
|
+
logger.debug("Registered unified error tool")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
__all__ = [
|
|
473
|
+
"register_unified_error_tool",
|
|
474
|
+
"perform_error_list",
|
|
475
|
+
"perform_error_get",
|
|
476
|
+
"perform_error_stats",
|
|
477
|
+
"perform_error_patterns",
|
|
478
|
+
"perform_error_cleanup",
|
|
479
|
+
]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Unified health tool with action routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from foundry_mcp.config import ServerConfig
|
|
13
|
+
from foundry_mcp.core.health import (
|
|
14
|
+
HealthStatus,
|
|
15
|
+
check_health,
|
|
16
|
+
check_liveness,
|
|
17
|
+
check_readiness,
|
|
18
|
+
)
|
|
19
|
+
from foundry_mcp.core.naming import canonical_tool
|
|
20
|
+
from foundry_mcp.core.prometheus import get_prometheus_exporter
|
|
21
|
+
from foundry_mcp.core.responses import (
|
|
22
|
+
ErrorCode,
|
|
23
|
+
ErrorType,
|
|
24
|
+
error_response,
|
|
25
|
+
success_response,
|
|
26
|
+
)
|
|
27
|
+
from foundry_mcp.tools.unified.router import (
|
|
28
|
+
ActionDefinition,
|
|
29
|
+
ActionRouter,
|
|
30
|
+
ActionRouterError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_ACTION_SUMMARY = {
|
|
37
|
+
"liveness": "Fast liveness probe for orchestrators",
|
|
38
|
+
"readiness": "Dependency-aware readiness probe",
|
|
39
|
+
"check": "Full health report with dependency details",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _status_to_int(status: HealthStatus) -> int:
|
|
44
|
+
"""Convert HealthStatus to Prometheus-friendly integer."""
|
|
45
|
+
|
|
46
|
+
return {"unhealthy": 0, "degraded": 1, "healthy": 2}.get(status.value, 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _record_batch_metrics(
|
|
50
|
+
check_type: str,
|
|
51
|
+
status: HealthStatus,
|
|
52
|
+
duration: float,
|
|
53
|
+
dependencies: Dict[str, bool] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
exporter = get_prometheus_exporter()
|
|
56
|
+
|
|
57
|
+
if dependencies is None:
|
|
58
|
+
exporter.record_health_check(
|
|
59
|
+
check_type=check_type,
|
|
60
|
+
status=_status_to_int(status),
|
|
61
|
+
duration_seconds=duration,
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
exporter.record_health_check_batch(
|
|
66
|
+
check_type=check_type,
|
|
67
|
+
status=_status_to_int(status),
|
|
68
|
+
dependencies=dependencies,
|
|
69
|
+
duration_seconds=duration,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def perform_health_liveness() -> dict:
|
|
74
|
+
"""Execute the liveness check and return serialized response."""
|
|
75
|
+
|
|
76
|
+
start_time = time.perf_counter()
|
|
77
|
+
try:
|
|
78
|
+
result = check_liveness()
|
|
79
|
+
duration = time.perf_counter() - start_time
|
|
80
|
+
_record_batch_metrics("liveness", result.status, duration)
|
|
81
|
+
|
|
82
|
+
return asdict(success_response(data=result.to_dict()))
|
|
83
|
+
except Exception as exc: # pragma: no cover - defensive safeguard
|
|
84
|
+
logger.exception("Error during liveness check")
|
|
85
|
+
return asdict(
|
|
86
|
+
error_response(
|
|
87
|
+
f"Liveness check failed: {exc}",
|
|
88
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
89
|
+
error_type=ErrorType.INTERNAL,
|
|
90
|
+
remediation="Check server logs and retry.",
|
|
91
|
+
details={"check_type": "liveness"},
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def perform_health_readiness() -> dict:
|
|
97
|
+
"""Execute the readiness check."""
|
|
98
|
+
|
|
99
|
+
start_time = time.perf_counter()
|
|
100
|
+
try:
|
|
101
|
+
result = check_readiness()
|
|
102
|
+
duration = time.perf_counter() - start_time
|
|
103
|
+
deps = {dep.name: dep.healthy for dep in result.dependencies}
|
|
104
|
+
_record_batch_metrics("readiness", result.status, duration, deps)
|
|
105
|
+
|
|
106
|
+
return asdict(success_response(data=result.to_dict()))
|
|
107
|
+
except Exception as exc: # pragma: no cover - defensive safeguard
|
|
108
|
+
logger.exception("Error during readiness check")
|
|
109
|
+
return asdict(
|
|
110
|
+
error_response(
|
|
111
|
+
f"Readiness check failed: {exc}",
|
|
112
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
113
|
+
error_type=ErrorType.INTERNAL,
|
|
114
|
+
remediation="Check server logs and retry.",
|
|
115
|
+
details={"check_type": "readiness"},
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def perform_health_check(include_details: bool = True) -> dict:
|
|
121
|
+
"""Execute the full health check."""
|
|
122
|
+
|
|
123
|
+
start_time = time.perf_counter()
|
|
124
|
+
try:
|
|
125
|
+
result = check_health()
|
|
126
|
+
duration = time.perf_counter() - start_time
|
|
127
|
+
deps = {dep.name: dep.healthy for dep in result.dependencies}
|
|
128
|
+
_record_batch_metrics("health", result.status, duration, deps)
|
|
129
|
+
|
|
130
|
+
data = result.to_dict()
|
|
131
|
+
if not include_details:
|
|
132
|
+
data.pop("dependencies", None)
|
|
133
|
+
|
|
134
|
+
return asdict(success_response(data=data))
|
|
135
|
+
except Exception as exc: # pragma: no cover - defensive safeguard
|
|
136
|
+
logger.exception("Error during health check")
|
|
137
|
+
return asdict(
|
|
138
|
+
error_response(
|
|
139
|
+
f"Health check failed: {exc}",
|
|
140
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
141
|
+
error_type=ErrorType.INTERNAL,
|
|
142
|
+
remediation="Check server logs and retry.",
|
|
143
|
+
details={"check_type": "check"},
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _handle_liveness(_: Any = None) -> dict:
|
|
149
|
+
return perform_health_liveness()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _handle_readiness(_: Any = None) -> dict:
|
|
153
|
+
return perform_health_readiness()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _handle_check(*, include_details: bool = True) -> dict:
|
|
157
|
+
return perform_health_check(include_details=include_details)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_router() -> ActionRouter:
|
|
161
|
+
definitions = [
|
|
162
|
+
ActionDefinition(
|
|
163
|
+
name="liveness",
|
|
164
|
+
handler=_handle_liveness,
|
|
165
|
+
summary=_ACTION_SUMMARY["liveness"],
|
|
166
|
+
),
|
|
167
|
+
ActionDefinition(
|
|
168
|
+
name="readiness",
|
|
169
|
+
handler=_handle_readiness,
|
|
170
|
+
summary=_ACTION_SUMMARY["readiness"],
|
|
171
|
+
),
|
|
172
|
+
ActionDefinition(
|
|
173
|
+
name="check",
|
|
174
|
+
handler=_handle_check,
|
|
175
|
+
summary=_ACTION_SUMMARY["check"],
|
|
176
|
+
),
|
|
177
|
+
]
|
|
178
|
+
return ActionRouter(tool_name="health", actions=definitions)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_HEALTH_ROUTER = _build_router()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _dispatch_health_action(action: str, *, include_details: bool = True) -> dict:
|
|
185
|
+
try:
|
|
186
|
+
kwargs: Dict[str, Any] = {}
|
|
187
|
+
if action.lower() == "check":
|
|
188
|
+
kwargs["include_details"] = include_details
|
|
189
|
+
return _HEALTH_ROUTER.dispatch(action=action, **kwargs)
|
|
190
|
+
except ActionRouterError as exc:
|
|
191
|
+
allowed = ", ".join(exc.allowed_actions)
|
|
192
|
+
return asdict(
|
|
193
|
+
error_response(
|
|
194
|
+
f"Unsupported health action '{action}'. Allowed actions: {allowed}",
|
|
195
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
196
|
+
error_type=ErrorType.VALIDATION,
|
|
197
|
+
remediation=f"Use one of: {allowed}",
|
|
198
|
+
details={"action": action, "allowed_actions": exc.allowed_actions},
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def register_unified_health_tool(mcp: FastMCP, config: ServerConfig) -> None:
|
|
204
|
+
"""Register the consolidated health tool."""
|
|
205
|
+
|
|
206
|
+
@canonical_tool(
|
|
207
|
+
mcp,
|
|
208
|
+
canonical_name="health",
|
|
209
|
+
)
|
|
210
|
+
def health(action: str, include_details: bool = True) -> dict:
|
|
211
|
+
"""Run health checks via `action` parameter.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
action: One of "liveness", "readiness", or "check".
|
|
215
|
+
include_details: When action is "check", controls dependency output.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
return _dispatch_health_action(action=action, include_details=include_details)
|
|
219
|
+
|
|
220
|
+
logger.debug("Registered unified health tool")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = [
|
|
224
|
+
"register_unified_health_tool",
|
|
225
|
+
]
|