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,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
+ ]