foundry-mcp 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +123 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,370 @@
1
+ """Unified test tool with action routing.
2
+
3
+ Provides the unified `test(action=...)` entry point.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import time
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional
13
+
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ from foundry_mcp.config import ServerConfig
17
+ from foundry_mcp.core.context import generate_correlation_id, get_correlation_id
18
+ from foundry_mcp.core.naming import canonical_tool
19
+ from foundry_mcp.core.observability import get_metrics, mcp_tool
20
+ from foundry_mcp.core.responses import (
21
+ ErrorCode,
22
+ ErrorType,
23
+ error_response,
24
+ success_response,
25
+ )
26
+ from foundry_mcp.core.testing import TestRunner, get_presets
27
+ from foundry_mcp.tools.unified.router import (
28
+ ActionDefinition,
29
+ ActionRouter,
30
+ ActionRouterError,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+ _metrics = get_metrics()
35
+
36
+
37
+ def _request_id() -> str:
38
+ return get_correlation_id() or generate_correlation_id(prefix="test")
39
+
40
+
41
+ def _metric(action: str) -> str:
42
+ return f"unified_tools.test.{action.replace('-', '_')}"
43
+
44
+
45
+ def _get_runner(config: ServerConfig, workspace: Optional[str]) -> TestRunner:
46
+ ws: Optional[Path] = None
47
+ if workspace:
48
+ ws = Path(workspace)
49
+ elif config.specs_dir is not None:
50
+ ws = config.specs_dir.parent
51
+ return TestRunner(workspace=ws)
52
+
53
+
54
+ def _validation_error(
55
+ *, message: str, request_id: str, remediation: Optional[str] = None
56
+ ) -> dict:
57
+ return asdict(
58
+ error_response(
59
+ message,
60
+ error_code=ErrorCode.VALIDATION_ERROR,
61
+ error_type=ErrorType.VALIDATION,
62
+ remediation=remediation,
63
+ request_id=request_id,
64
+ )
65
+ )
66
+
67
+
68
+ def _handle_run(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
69
+ request_id = _request_id()
70
+
71
+ preset = payload.get("preset")
72
+ if preset is not None and not isinstance(preset, str):
73
+ return _validation_error(
74
+ message="preset must be a string",
75
+ request_id=request_id,
76
+ remediation="Use preset=quick|unit|full",
77
+ )
78
+
79
+ if isinstance(preset, str):
80
+ presets = get_presets()
81
+ if preset not in presets:
82
+ return _validation_error(
83
+ message=f"Unknown preset: {preset}",
84
+ request_id=request_id,
85
+ remediation=f"Use one of: {', '.join(sorted(presets))}",
86
+ )
87
+
88
+ target = payload.get("target")
89
+ if target is not None and not isinstance(target, str):
90
+ return _validation_error(
91
+ message="target must be a string",
92
+ request_id=request_id,
93
+ remediation="Provide a pytest target like tests/unit or tests/test_file.py",
94
+ )
95
+
96
+ timeout = payload.get("timeout", 300)
97
+ if timeout is not None:
98
+ try:
99
+ timeout_int = int(timeout)
100
+ except (TypeError, ValueError):
101
+ return _validation_error(
102
+ message="timeout must be an integer",
103
+ request_id=request_id,
104
+ remediation="Provide a timeout in seconds",
105
+ )
106
+ if timeout_int <= 0:
107
+ return _validation_error(
108
+ message="timeout must be > 0",
109
+ request_id=request_id,
110
+ remediation="Provide a timeout in seconds",
111
+ )
112
+ timeout = timeout_int
113
+
114
+ verbose_value = payload.get("verbose", True)
115
+ if verbose_value is not None and not isinstance(verbose_value, bool):
116
+ return _validation_error(
117
+ message="verbose must be a boolean",
118
+ request_id=request_id,
119
+ remediation="Provide verbose=true|false",
120
+ )
121
+ verbose = verbose_value if isinstance(verbose_value, bool) else True
122
+
123
+ fail_fast_value = payload.get("fail_fast", False)
124
+ if fail_fast_value is not None and not isinstance(fail_fast_value, bool):
125
+ return _validation_error(
126
+ message="fail_fast must be a boolean",
127
+ request_id=request_id,
128
+ remediation="Provide fail_fast=true|false",
129
+ )
130
+ fail_fast = fail_fast_value if isinstance(fail_fast_value, bool) else False
131
+ markers = payload.get("markers")
132
+ if markers is not None and not isinstance(markers, str):
133
+ return _validation_error(
134
+ message="markers must be a string",
135
+ request_id=request_id,
136
+ remediation="Provide a pytest markers expression like 'not slow'",
137
+ )
138
+
139
+ workspace = payload.get("workspace")
140
+ if workspace is not None and not isinstance(workspace, str):
141
+ return _validation_error(
142
+ message="workspace must be a string",
143
+ request_id=request_id,
144
+ remediation="Provide an absolute path to the workspace",
145
+ )
146
+
147
+ include_passed_value = payload.get("include_passed", False)
148
+ if include_passed_value is not None and not isinstance(include_passed_value, bool):
149
+ return _validation_error(
150
+ message="include_passed must be a boolean",
151
+ request_id=request_id,
152
+ remediation="Provide include_passed=true|false",
153
+ )
154
+ include_passed = (
155
+ include_passed_value if isinstance(include_passed_value, bool) else False
156
+ )
157
+
158
+ runner = _get_runner(config, workspace)
159
+
160
+ start = time.perf_counter()
161
+ result = runner.run_tests(
162
+ target=target,
163
+ preset=preset,
164
+ timeout=timeout,
165
+ verbose=verbose,
166
+ fail_fast=fail_fast,
167
+ markers=markers,
168
+ )
169
+ elapsed_ms = (time.perf_counter() - start) * 1000
170
+
171
+ _metrics.timer(_metric("run") + ".duration_ms", elapsed_ms)
172
+ _metrics.counter(
173
+ _metric("run"), labels={"status": "success" if result.success else "failure"}
174
+ )
175
+
176
+ if result.error:
177
+ return asdict(
178
+ error_response(
179
+ result.error,
180
+ error_code=ErrorCode.INTERNAL_ERROR,
181
+ error_type=ErrorType.INTERNAL,
182
+ request_id=request_id,
183
+ )
184
+ )
185
+
186
+ filtered_tests = (
187
+ result.tests
188
+ if include_passed
189
+ else [t for t in result.tests if t.outcome in ("failed", "error")]
190
+ )
191
+
192
+ return asdict(
193
+ success_response(
194
+ execution_id=result.execution_id,
195
+ timestamp=result.timestamp,
196
+ tests_passed=result.success,
197
+ summary={
198
+ "total": result.total,
199
+ "passed": result.passed,
200
+ "failed": result.failed,
201
+ "skipped": result.skipped,
202
+ "errors": result.errors,
203
+ },
204
+ tests=[
205
+ {
206
+ "name": t.name,
207
+ "outcome": t.outcome,
208
+ "duration": t.duration,
209
+ "message": t.message,
210
+ }
211
+ for t in filtered_tests
212
+ ],
213
+ filtered=not include_passed,
214
+ command=result.command,
215
+ duration=result.duration,
216
+ metadata=dict(result.metadata or {}),
217
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
218
+ request_id=request_id,
219
+ )
220
+ )
221
+
222
+
223
+ def _handle_discover(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
224
+ request_id = _request_id()
225
+
226
+ target = payload.get("target")
227
+ if target is not None and not isinstance(target, str):
228
+ return _validation_error(
229
+ message="target must be a string",
230
+ request_id=request_id,
231
+ remediation="Provide a test directory or file to search",
232
+ )
233
+
234
+ pattern = payload.get("pattern", "test_*.py")
235
+ if not isinstance(pattern, str) or not pattern:
236
+ return _validation_error(
237
+ message="pattern must be a non-empty string",
238
+ request_id=request_id,
239
+ remediation="Provide a file glob pattern like test_*.py",
240
+ )
241
+
242
+ workspace = payload.get("workspace")
243
+ if workspace is not None and not isinstance(workspace, str):
244
+ return _validation_error(
245
+ message="workspace must be a string",
246
+ request_id=request_id,
247
+ remediation="Provide an absolute path to the workspace",
248
+ )
249
+
250
+ runner = _get_runner(config, workspace)
251
+
252
+ start = time.perf_counter()
253
+ result = runner.discover_tests(target=target, pattern=pattern)
254
+ elapsed_ms = (time.perf_counter() - start) * 1000
255
+
256
+ _metrics.timer(_metric("discover") + ".duration_ms", elapsed_ms)
257
+ _metrics.counter(
258
+ _metric("discover"),
259
+ labels={"status": "success" if result.success else "failure"},
260
+ )
261
+
262
+ if result.error:
263
+ return asdict(
264
+ error_response(
265
+ result.error,
266
+ error_code=ErrorCode.INTERNAL_ERROR,
267
+ error_type=ErrorType.INTERNAL,
268
+ request_id=request_id,
269
+ )
270
+ )
271
+
272
+ return asdict(
273
+ success_response(
274
+ timestamp=result.timestamp,
275
+ total=result.total,
276
+ test_files=result.test_files,
277
+ tests=[
278
+ {
279
+ "name": t.name,
280
+ "file_path": t.file_path,
281
+ "line_number": t.line_number,
282
+ "markers": t.markers,
283
+ }
284
+ for t in result.tests
285
+ ],
286
+ metadata=dict(result.metadata or {}),
287
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
288
+ request_id=request_id,
289
+ )
290
+ )
291
+
292
+
293
+ _ACTION_SUMMARY = {
294
+ "run": "Execute pytest using a preset or target.",
295
+ "discover": "Discover pytest tests without executing.",
296
+ }
297
+
298
+
299
+ def _build_router() -> ActionRouter:
300
+ actions = [
301
+ ActionDefinition(
302
+ name="run", handler=_handle_run, summary=_ACTION_SUMMARY["run"]
303
+ ),
304
+ ActionDefinition(
305
+ name="discover",
306
+ handler=_handle_discover,
307
+ summary=_ACTION_SUMMARY["discover"],
308
+ ),
309
+ ]
310
+ return ActionRouter(tool_name="test", actions=actions)
311
+
312
+
313
+ _TEST_ROUTER = _build_router()
314
+
315
+
316
+ def _dispatch_test_action(
317
+ *, action: str, payload: Dict[str, Any], config: ServerConfig
318
+ ) -> dict:
319
+ try:
320
+ return _TEST_ROUTER.dispatch(action, config=config, payload=payload)
321
+ except ActionRouterError as exc:
322
+ allowed = ", ".join(exc.allowed_actions)
323
+ request_id = _request_id()
324
+ return asdict(
325
+ error_response(
326
+ f"Unsupported test action '{action}'. Allowed actions: {allowed}",
327
+ error_code=ErrorCode.VALIDATION_ERROR,
328
+ error_type=ErrorType.VALIDATION,
329
+ remediation=f"Use one of: {allowed}",
330
+ request_id=request_id,
331
+ )
332
+ )
333
+
334
+
335
+ def register_unified_test_tool(mcp: FastMCP, config: ServerConfig) -> None:
336
+ """Register the consolidated test tool."""
337
+
338
+ @canonical_tool(mcp, canonical_name="test")
339
+ @mcp_tool(tool_name="test", emit_metrics=True, audit=True)
340
+ def test(
341
+ action: str,
342
+ target: Optional[str] = None,
343
+ preset: Optional[str] = None,
344
+ timeout: int = 300,
345
+ verbose: bool = True,
346
+ fail_fast: bool = False,
347
+ markers: Optional[str] = None,
348
+ pattern: str = "test_*.py",
349
+ workspace: Optional[str] = None,
350
+ include_passed: bool = False,
351
+ ) -> dict:
352
+ payload: Dict[str, Any] = {
353
+ "target": target,
354
+ "preset": preset,
355
+ "timeout": timeout,
356
+ "verbose": verbose,
357
+ "fail_fast": fail_fast,
358
+ "markers": markers,
359
+ "pattern": pattern,
360
+ "workspace": workspace,
361
+ "include_passed": include_passed,
362
+ }
363
+ return _dispatch_test_action(action=action, payload=payload, config=config)
364
+
365
+ logger.debug("Registered unified test tool")
366
+
367
+
368
+ __all__ = [
369
+ "register_unified_test_tool",
370
+ ]