foundry-mcp 0.3.3__py3-none-any.whl → 0.8.10__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 (85) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/__init__.py +0 -13
  3. foundry_mcp/cli/commands/plan.py +10 -3
  4. foundry_mcp/cli/commands/review.py +19 -4
  5. foundry_mcp/cli/commands/session.py +1 -8
  6. foundry_mcp/cli/commands/specs.py +38 -208
  7. foundry_mcp/cli/context.py +39 -0
  8. foundry_mcp/cli/output.py +3 -3
  9. foundry_mcp/config.py +615 -11
  10. foundry_mcp/core/ai_consultation.py +146 -9
  11. foundry_mcp/core/batch_operations.py +1196 -0
  12. foundry_mcp/core/discovery.py +7 -7
  13. foundry_mcp/core/error_store.py +2 -2
  14. foundry_mcp/core/intake.py +933 -0
  15. foundry_mcp/core/llm_config.py +28 -2
  16. foundry_mcp/core/metrics_store.py +2 -2
  17. foundry_mcp/core/naming.py +25 -2
  18. foundry_mcp/core/progress.py +70 -0
  19. foundry_mcp/core/prometheus.py +0 -13
  20. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  21. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  22. foundry_mcp/core/prompts/plan_review.py +5 -1
  23. foundry_mcp/core/providers/__init__.py +12 -0
  24. foundry_mcp/core/providers/base.py +39 -0
  25. foundry_mcp/core/providers/claude.py +51 -48
  26. foundry_mcp/core/providers/codex.py +70 -60
  27. foundry_mcp/core/providers/cursor_agent.py +25 -47
  28. foundry_mcp/core/providers/detectors.py +34 -7
  29. foundry_mcp/core/providers/gemini.py +69 -58
  30. foundry_mcp/core/providers/opencode.py +101 -47
  31. foundry_mcp/core/providers/package-lock.json +4 -4
  32. foundry_mcp/core/providers/package.json +1 -1
  33. foundry_mcp/core/providers/validation.py +128 -0
  34. foundry_mcp/core/research/__init__.py +68 -0
  35. foundry_mcp/core/research/memory.py +528 -0
  36. foundry_mcp/core/research/models.py +1220 -0
  37. foundry_mcp/core/research/providers/__init__.py +40 -0
  38. foundry_mcp/core/research/providers/base.py +242 -0
  39. foundry_mcp/core/research/providers/google.py +507 -0
  40. foundry_mcp/core/research/providers/perplexity.py +442 -0
  41. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  42. foundry_mcp/core/research/providers/tavily.py +383 -0
  43. foundry_mcp/core/research/workflows/__init__.py +25 -0
  44. foundry_mcp/core/research/workflows/base.py +298 -0
  45. foundry_mcp/core/research/workflows/chat.py +271 -0
  46. foundry_mcp/core/research/workflows/consensus.py +539 -0
  47. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  48. foundry_mcp/core/research/workflows/ideate.py +682 -0
  49. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  50. foundry_mcp/core/responses.py +690 -0
  51. foundry_mcp/core/spec.py +2439 -236
  52. foundry_mcp/core/task.py +1205 -31
  53. foundry_mcp/core/testing.py +512 -123
  54. foundry_mcp/core/validation.py +319 -43
  55. foundry_mcp/dashboard/components/charts.py +0 -57
  56. foundry_mcp/dashboard/launcher.py +11 -0
  57. foundry_mcp/dashboard/views/metrics.py +25 -35
  58. foundry_mcp/dashboard/views/overview.py +1 -65
  59. foundry_mcp/resources/specs.py +25 -25
  60. foundry_mcp/schemas/intake-schema.json +89 -0
  61. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  62. foundry_mcp/server.py +0 -14
  63. foundry_mcp/tools/unified/__init__.py +39 -18
  64. foundry_mcp/tools/unified/authoring.py +2371 -248
  65. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  66. foundry_mcp/tools/unified/environment.py +434 -32
  67. foundry_mcp/tools/unified/error.py +18 -1
  68. foundry_mcp/tools/unified/lifecycle.py +8 -0
  69. foundry_mcp/tools/unified/plan.py +133 -2
  70. foundry_mcp/tools/unified/provider.py +0 -40
  71. foundry_mcp/tools/unified/research.py +1283 -0
  72. foundry_mcp/tools/unified/review.py +374 -17
  73. foundry_mcp/tools/unified/review_helpers.py +16 -1
  74. foundry_mcp/tools/unified/server.py +9 -24
  75. foundry_mcp/tools/unified/spec.py +367 -0
  76. foundry_mcp/tools/unified/task.py +1664 -30
  77. foundry_mcp/tools/unified/test.py +69 -8
  78. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
  79. foundry_mcp-0.8.10.dist-info/RECORD +153 -0
  80. foundry_mcp/cli/flags.py +0 -266
  81. foundry_mcp/core/feature_flags.py +0 -592
  82. foundry_mcp-0.3.3.dist-info/RECORD +0 -135
  83. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  84. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  85. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
@@ -209,6 +209,7 @@ def _handle_move(
209
209
  spec_id: Optional[str] = None,
210
210
  to_folder: Optional[str] = None,
211
211
  path: Optional[str] = None,
212
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
212
213
  ) -> dict:
213
214
  action = "move"
214
215
  request_id = _request_id()
@@ -293,7 +294,9 @@ def _handle_activate(
293
294
  *,
294
295
  config: ServerConfig,
295
296
  spec_id: Optional[str] = None,
297
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
296
298
  path: Optional[str] = None,
299
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
297
300
  ) -> dict:
298
301
  action = "activate"
299
302
  request_id = _request_id()
@@ -356,6 +359,7 @@ def _handle_complete(
356
359
  *,
357
360
  config: ServerConfig,
358
361
  spec_id: Optional[str] = None,
362
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
359
363
  force: Optional[bool] = False,
360
364
  path: Optional[str] = None,
361
365
  ) -> dict:
@@ -429,7 +433,9 @@ def _handle_archive(
429
433
  *,
430
434
  config: ServerConfig,
431
435
  spec_id: Optional[str] = None,
436
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
432
437
  path: Optional[str] = None,
438
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
433
439
  ) -> dict:
434
440
  action = "archive"
435
441
  request_id = _request_id()
@@ -492,7 +498,9 @@ def _handle_state(
492
498
  *,
493
499
  config: ServerConfig,
494
500
  spec_id: Optional[str] = None,
501
+ to_folder: Optional[str] = None, # Unused, accepted for router compatibility
495
502
  path: Optional[str] = None,
503
+ force: Optional[bool] = None, # Unused, accepted for router compatibility
496
504
  ) -> dict:
497
505
  action = "state"
498
506
  request_id = _request_id()
@@ -19,6 +19,7 @@ from foundry_mcp.core.ai_consultation import (
19
19
  ConsultationWorkflow,
20
20
  ConsensusResult,
21
21
  )
22
+ from foundry_mcp.core.llm_config import load_consultation_config
22
23
  from foundry_mcp.core.naming import canonical_tool
23
24
  from foundry_mcp.core.observability import get_metrics, mcp_tool
24
25
  from foundry_mcp.core.providers import available_providers
@@ -29,6 +30,7 @@ from foundry_mcp.core.responses import (
29
30
  error_response,
30
31
  success_response,
31
32
  )
33
+ from foundry_mcp.core.llm_config import load_consultation_config
32
34
  from foundry_mcp.core.security import is_prompt_injection
33
35
  from foundry_mcp.core.spec import find_specs_directory
34
36
  from foundry_mcp.tools.unified.router import (
@@ -55,6 +57,20 @@ def _extract_plan_name(plan_path: str) -> str:
55
57
  return Path(plan_path).stem
56
58
 
57
59
 
60
+ def _find_config_file(start_path: Path) -> Optional[Path]:
61
+ """Find foundry-mcp.toml by walking up from start_path."""
62
+ current = start_path if start_path.is_dir() else start_path.parent
63
+ for _ in range(10): # Limit depth to prevent infinite loops
64
+ config_file = current / "foundry-mcp.toml"
65
+ if config_file.exists():
66
+ return config_file
67
+ parent = current.parent
68
+ if parent == current: # Reached root
69
+ break
70
+ current = parent
71
+ return None
72
+
73
+
58
74
  def _parse_review_summary(content: str) -> dict:
59
75
  """Parse review markdown to extract section counts."""
60
76
 
@@ -323,7 +339,10 @@ def perform_plan_review(
323
339
  template_id = REVIEW_TYPE_TO_TEMPLATE[review_type]
324
340
 
325
341
  try:
326
- orchestrator = ConsultationOrchestrator()
342
+ # Load consultation config from workspace to get provider priority list
343
+ config_file = _find_config_file(plan_file)
344
+ consultation_config = load_consultation_config(config_file=config_file)
345
+ orchestrator = ConsultationOrchestrator(config=consultation_config)
327
346
  request = ConsultationRequest(
328
347
  workflow=ConsultationWorkflow.MARKDOWN_PLAN_REVIEW,
329
348
  prompt_id=template_id,
@@ -339,6 +358,7 @@ def perform_plan_review(
339
358
 
340
359
  consensus_info: Optional[dict] = None
341
360
  provider_used: Optional[str] = None
361
+ provider_reviews: list[dict[str, str]] = []
342
362
 
343
363
  if isinstance(result, ConsultationResult):
344
364
  if not result.success:
@@ -362,16 +382,125 @@ def perform_plan_review(
362
382
  remediation="Check AI provider configuration or try again later",
363
383
  )
364
384
  )
365
- review_content = result.primary_content
385
+
366
386
  providers_consulted = [r.provider_id for r in result.responses]
367
387
  provider_used = providers_consulted[0] if providers_consulted else "unknown"
388
+
389
+ # Extract failed provider details for visibility
390
+ failed_providers = [
391
+ {"provider_id": r.provider_id, "error": r.error}
392
+ for r in result.responses
393
+ if not r.success
394
+ ]
395
+ # Filter for truly successful responses (success=True AND non-empty content)
396
+ successful_responses = [
397
+ r for r in result.responses if r.success and r.content.strip()
398
+ ]
399
+ successful_providers = [r.provider_id for r in successful_responses]
400
+
368
401
  consensus_info = {
369
402
  "providers_consulted": providers_consulted,
370
403
  "successful": result.agreement.successful_providers
371
404
  if result.agreement
372
405
  else 0,
373
406
  "failed": result.agreement.failed_providers if result.agreement else 0,
407
+ "successful_providers": successful_providers,
408
+ "failed_providers": failed_providers,
374
409
  }
410
+
411
+ # Save individual provider review files and optionally run synthesis
412
+ if len(successful_responses) >= 2:
413
+ # Multi-model mode: save per-provider files, then synthesize
414
+ specs_dir = find_specs_directory()
415
+ if specs_dir is None:
416
+ return asdict(
417
+ error_response(
418
+ "No specs directory found for storing plan review",
419
+ error_code=ErrorCode.NOT_FOUND,
420
+ error_type=ErrorType.NOT_FOUND,
421
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
422
+ )
423
+ )
424
+
425
+ plan_reviews_dir = specs_dir / ".plan-reviews"
426
+ plan_reviews_dir.mkdir(parents=True, exist_ok=True)
427
+
428
+ # Save each provider's review to a separate file
429
+ model_reviews_text = ""
430
+ for response in successful_responses:
431
+ provider_file = (
432
+ plan_reviews_dir
433
+ / f"{plan_name}-{review_type}-{response.provider_id}.md"
434
+ )
435
+ provider_file.write_text(response.content, encoding="utf-8")
436
+ provider_reviews.append(
437
+ {"provider_id": response.provider_id, "path": str(provider_file)}
438
+ )
439
+ model_reviews_text += (
440
+ f"\n---\n## Review by {response.provider_id}\n\n"
441
+ f"{response.content}\n"
442
+ )
443
+
444
+ # Run synthesis call using first provider
445
+ logger.info(
446
+ "Running synthesis for %d provider reviews: %s",
447
+ len(successful_responses),
448
+ successful_providers,
449
+ )
450
+ synthesis_request = ConsultationRequest(
451
+ workflow=ConsultationWorkflow.PLAN_REVIEW,
452
+ prompt_id="SYNTHESIS_PROMPT_V1",
453
+ context={
454
+ "spec_id": plan_name,
455
+ "title": plan_name,
456
+ "num_models": len(successful_responses),
457
+ "model_reviews": model_reviews_text,
458
+ },
459
+ provider_id=successful_providers[0],
460
+ timeout=ai_timeout,
461
+ )
462
+ try:
463
+ synthesis_result = orchestrator.consult(
464
+ synthesis_request, use_cache=consultation_cache
465
+ )
466
+ except Exception as e:
467
+ logger.error("Synthesis call crashed: %s", e, exc_info=True)
468
+ synthesis_result = None
469
+
470
+ # Handle both ConsultationResult and ConsensusResult
471
+ synthesis_success = False
472
+ synthesis_content = None
473
+ if synthesis_result:
474
+ if isinstance(synthesis_result, ConsultationResult) and synthesis_result.success:
475
+ synthesis_content = synthesis_result.content
476
+ consensus_info["synthesis_provider"] = synthesis_result.provider_id
477
+ synthesis_success = bool(synthesis_content and synthesis_content.strip())
478
+ elif isinstance(synthesis_result, ConsensusResult) and synthesis_result.success:
479
+ synthesis_content = synthesis_result.primary_content
480
+ consensus_info["synthesis_provider"] = synthesis_result.responses[0].provider_id if synthesis_result.responses else "unknown"
481
+ synthesis_success = bool(synthesis_content and synthesis_content.strip())
482
+
483
+ if synthesis_success and synthesis_content:
484
+ review_content = synthesis_content
485
+ else:
486
+ # Synthesis failed - fall back to first provider's content
487
+ error_detail = "unknown"
488
+ if synthesis_result is None:
489
+ error_detail = "synthesis crashed (see logs)"
490
+ elif isinstance(synthesis_result, ConsultationResult):
491
+ error_detail = synthesis_result.error or "empty response"
492
+ elif isinstance(synthesis_result, ConsensusResult):
493
+ error_detail = "empty synthesis content"
494
+ logger.warning(
495
+ "Synthesis call failed (%s), falling back to first provider's content",
496
+ error_detail,
497
+ )
498
+ review_content = result.primary_content
499
+ consensus_info["synthesis_failed"] = True
500
+ consensus_info["synthesis_error"] = error_detail
501
+ else:
502
+ # Single successful provider - use its content directly (no synthesis needed)
503
+ review_content = result.primary_content
375
504
  else: # pragma: no cover - defensive branch
376
505
  logger.error("Unknown consultation result type: %s", type(result))
377
506
  return asdict(
@@ -444,6 +573,8 @@ def perform_plan_review(
444
573
  "llm_status": llm_status,
445
574
  "provider_used": provider_used,
446
575
  }
576
+ if provider_reviews:
577
+ response_data["provider_reviews"] = provider_reviews
447
578
  if consensus_info:
448
579
  response_data["consensus"] = consensus_info
449
580
 
@@ -11,7 +11,6 @@ from mcp.server.fastmcp import FastMCP
11
11
 
12
12
  from foundry_mcp.config import ServerConfig
13
13
  from foundry_mcp.core.context import generate_correlation_id, get_correlation_id
14
- from foundry_mcp.core.feature_flags import FeatureFlag, FlagState, get_flag_service
15
14
  from foundry_mcp.core.llm_provider import RateLimitError
16
15
  from foundry_mcp.core.naming import canonical_tool
17
16
  from foundry_mcp.core.observability import get_metrics, mcp_tool
@@ -42,19 +41,6 @@ from foundry_mcp.tools.unified.router import (
42
41
 
43
42
  logger = logging.getLogger(__name__)
44
43
  _metrics = get_metrics()
45
- _flag_service = get_flag_service()
46
- try:
47
- _flag_service.register(
48
- FeatureFlag(
49
- name="provider_tools",
50
- description="LLM provider management and execution tools",
51
- state=FlagState.BETA,
52
- default_enabled=True,
53
- )
54
- )
55
- except ValueError:
56
- # Flag already registered
57
- pass
58
44
 
59
45
  _ACTION_SUMMARY = {
60
46
  "list": "List registered providers with optional unavailable entries",
@@ -92,22 +78,6 @@ def _validation_error(
92
78
  )
93
79
 
94
80
 
95
- def _feature_flag_blocked(request_id: str) -> Optional[dict]:
96
- if _flag_service.is_enabled("provider_tools"):
97
- return None
98
-
99
- return asdict(
100
- error_response(
101
- "Provider tools are disabled by feature flag",
102
- error_code=ErrorCode.FEATURE_DISABLED,
103
- error_type=ErrorType.FEATURE_FLAG,
104
- data={"feature": "provider_tools"},
105
- remediation="Enable the 'provider_tools' feature flag to call provider actions.",
106
- request_id=request_id,
107
- )
108
- )
109
-
110
-
111
81
  def _handle_list(
112
82
  *,
113
83
  config: ServerConfig, # noqa: ARG001 - reserved for future hooks
@@ -115,9 +85,6 @@ def _handle_list(
115
85
  **_: Any,
116
86
  ) -> dict:
117
87
  request_id = _request_id()
118
- blocked = _feature_flag_blocked(request_id)
119
- if blocked:
120
- return blocked
121
88
 
122
89
  include = include_unavailable if isinstance(include_unavailable, bool) else False
123
90
  if include_unavailable is not None and not isinstance(include_unavailable, bool):
@@ -182,9 +149,6 @@ def _handle_status(
182
149
  **_: Any,
183
150
  ) -> dict:
184
151
  request_id = _request_id()
185
- blocked = _feature_flag_blocked(request_id)
186
- if blocked:
187
- return blocked
188
152
 
189
153
  if not isinstance(provider_id, str) or not provider_id.strip():
190
154
  return _validation_error(
@@ -288,10 +252,6 @@ def _handle_execute(
288
252
  **_: Any,
289
253
  ) -> dict:
290
254
  request_id = _request_id()
291
- blocked = _feature_flag_blocked(request_id)
292
- if blocked:
293
- return blocked
294
-
295
255
  action = "execute"
296
256
 
297
257
  if not isinstance(provider_id, str) or not provider_id.strip():