foundry-mcp 0.3.3__py3-none-any.whl → 0.7.0__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 (60) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/commands/plan.py +10 -3
  3. foundry_mcp/cli/commands/review.py +19 -4
  4. foundry_mcp/cli/commands/specs.py +38 -208
  5. foundry_mcp/cli/output.py +3 -3
  6. foundry_mcp/config.py +235 -5
  7. foundry_mcp/core/ai_consultation.py +146 -9
  8. foundry_mcp/core/discovery.py +6 -6
  9. foundry_mcp/core/error_store.py +2 -2
  10. foundry_mcp/core/intake.py +933 -0
  11. foundry_mcp/core/llm_config.py +20 -2
  12. foundry_mcp/core/metrics_store.py +2 -2
  13. foundry_mcp/core/progress.py +70 -0
  14. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  15. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  16. foundry_mcp/core/prompts/plan_review.py +5 -1
  17. foundry_mcp/core/providers/claude.py +6 -47
  18. foundry_mcp/core/providers/codex.py +6 -57
  19. foundry_mcp/core/providers/cursor_agent.py +3 -44
  20. foundry_mcp/core/providers/gemini.py +6 -57
  21. foundry_mcp/core/providers/opencode.py +35 -5
  22. foundry_mcp/core/research/__init__.py +68 -0
  23. foundry_mcp/core/research/memory.py +425 -0
  24. foundry_mcp/core/research/models.py +437 -0
  25. foundry_mcp/core/research/workflows/__init__.py +22 -0
  26. foundry_mcp/core/research/workflows/base.py +204 -0
  27. foundry_mcp/core/research/workflows/chat.py +271 -0
  28. foundry_mcp/core/research/workflows/consensus.py +396 -0
  29. foundry_mcp/core/research/workflows/ideate.py +682 -0
  30. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  31. foundry_mcp/core/responses.py +450 -0
  32. foundry_mcp/core/spec.py +2438 -236
  33. foundry_mcp/core/task.py +1064 -19
  34. foundry_mcp/core/testing.py +512 -123
  35. foundry_mcp/core/validation.py +313 -42
  36. foundry_mcp/dashboard/components/charts.py +0 -57
  37. foundry_mcp/dashboard/launcher.py +11 -0
  38. foundry_mcp/dashboard/views/metrics.py +25 -35
  39. foundry_mcp/dashboard/views/overview.py +1 -65
  40. foundry_mcp/resources/specs.py +25 -25
  41. foundry_mcp/schemas/intake-schema.json +89 -0
  42. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  43. foundry_mcp/server.py +38 -0
  44. foundry_mcp/tools/unified/__init__.py +4 -2
  45. foundry_mcp/tools/unified/authoring.py +2423 -267
  46. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  47. foundry_mcp/tools/unified/environment.py +235 -6
  48. foundry_mcp/tools/unified/error.py +18 -1
  49. foundry_mcp/tools/unified/lifecycle.py +8 -0
  50. foundry_mcp/tools/unified/plan.py +113 -1
  51. foundry_mcp/tools/unified/research.py +658 -0
  52. foundry_mcp/tools/unified/review.py +370 -16
  53. foundry_mcp/tools/unified/spec.py +367 -0
  54. foundry_mcp/tools/unified/task.py +1163 -48
  55. foundry_mcp/tools/unified/test.py +69 -8
  56. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/METADATA +7 -1
  57. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/RECORD +60 -48
  58. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/WHEEL +0 -0
  59. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
  60. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import logging
11
11
  import time
12
+ from datetime import datetime
12
13
  from dataclasses import asdict
13
14
  from pathlib import Path
14
15
  from typing import Any, Dict, List, Optional
@@ -19,9 +20,14 @@ from foundry_mcp.config import ServerConfig
19
20
  from foundry_mcp.core.ai_consultation import (
20
21
  ConsultationOrchestrator,
21
22
  ConsultationRequest,
23
+ ConsultationResult,
22
24
  ConsultationWorkflow,
23
25
  ConsensusResult,
24
26
  )
27
+ from foundry_mcp.core.prompts.fidelity_review import (
28
+ FIDELITY_SYNTHESIZED_RESPONSE_SCHEMA,
29
+ )
30
+ from foundry_mcp.core.llm_config import get_consultation_config
25
31
  from foundry_mcp.core.naming import canonical_tool
26
32
  from foundry_mcp.core.observability import get_metrics, mcp_tool
27
33
  from foundry_mcp.core.providers import get_provider_statuses
@@ -82,7 +88,11 @@ def _parse_json_content(content: str) -> Optional[dict]:
82
88
 
83
89
  def _handle_spec_review(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
84
90
  spec_id = payload.get("spec_id")
85
- review_type = payload.get("review_type", "quick")
91
+ # Get default review_type from consultation config (used when not provided or None)
92
+ consultation_config = get_consultation_config()
93
+ workflow_config = consultation_config.get_workflow_config("plan_review")
94
+ default_review_type = workflow_config.default_review_type
95
+ review_type = payload.get("review_type") or default_review_type
86
96
 
87
97
  if not isinstance(spec_id, str) or not spec_id.strip():
88
98
  return asdict(
@@ -360,6 +370,159 @@ def _handle_parse_feedback(*, config: ServerConfig, payload: Dict[str, Any]) ->
360
370
  )
361
371
 
362
372
 
373
+ def _format_fidelity_markdown(
374
+ parsed: Dict[str, Any],
375
+ spec_id: str,
376
+ spec_title: str,
377
+ scope: str,
378
+ task_id: Optional[str] = None,
379
+ phase_id: Optional[str] = None,
380
+ provider_id: Optional[str] = None,
381
+ ) -> str:
382
+ """Format fidelity review JSON as human-readable markdown."""
383
+ # Build scope detail
384
+ scope_detail = scope
385
+ if task_id:
386
+ scope_detail += f" (task: {task_id})"
387
+ elif phase_id:
388
+ scope_detail += f" (phase: {phase_id})"
389
+
390
+ lines = [
391
+ f"# Fidelity Review: {spec_title}",
392
+ "",
393
+ f"**Spec ID:** {spec_id}",
394
+ f"**Scope:** {scope_detail}",
395
+ f"**Verdict:** {parsed.get('verdict', 'unknown')}",
396
+ f"**Date:** {datetime.now().isoformat()}",
397
+ ]
398
+ if provider_id:
399
+ lines.append(f"**Provider:** {provider_id}")
400
+ lines.append("")
401
+
402
+ # Summary section
403
+ if parsed.get("summary"):
404
+ lines.extend(["## Summary", "", parsed["summary"], ""])
405
+
406
+ # Requirement Alignment
407
+ req_align = parsed.get("requirement_alignment", {})
408
+ if req_align:
409
+ lines.extend([
410
+ "## Requirement Alignment",
411
+ f"**Status:** {req_align.get('answer', 'unknown')}",
412
+ "",
413
+ req_align.get("details", ""),
414
+ "",
415
+ ])
416
+
417
+ # Success Criteria
418
+ success = parsed.get("success_criteria", {})
419
+ if success:
420
+ lines.extend([
421
+ "## Success Criteria",
422
+ f"**Status:** {success.get('met', 'unknown')}",
423
+ "",
424
+ success.get("details", ""),
425
+ "",
426
+ ])
427
+
428
+ # Deviations
429
+ deviations = parsed.get("deviations", [])
430
+ if deviations:
431
+ lines.extend(["## Deviations", ""])
432
+ for dev in deviations:
433
+ severity = dev.get("severity", "unknown")
434
+ description = dev.get("description", "")
435
+ justification = dev.get("justification", "")
436
+ lines.append(f"- **[{severity.upper()}]** {description}")
437
+ if justification:
438
+ lines.append(f" - Justification: {justification}")
439
+ lines.append("")
440
+
441
+ # Test Coverage
442
+ test_cov = parsed.get("test_coverage", {})
443
+ if test_cov:
444
+ lines.extend([
445
+ "## Test Coverage",
446
+ f"**Status:** {test_cov.get('status', 'unknown')}",
447
+ "",
448
+ test_cov.get("details", ""),
449
+ "",
450
+ ])
451
+
452
+ # Code Quality
453
+ code_quality = parsed.get("code_quality", {})
454
+ if code_quality:
455
+ lines.extend(["## Code Quality", ""])
456
+ if code_quality.get("details"):
457
+ lines.append(code_quality["details"])
458
+ lines.append("")
459
+ for issue in code_quality.get("issues", []):
460
+ lines.append(f"- {issue}")
461
+ lines.append("")
462
+
463
+ # Documentation
464
+ doc = parsed.get("documentation", {})
465
+ if doc:
466
+ lines.extend([
467
+ "## Documentation",
468
+ f"**Status:** {doc.get('status', 'unknown')}",
469
+ "",
470
+ doc.get("details", ""),
471
+ "",
472
+ ])
473
+
474
+ # Issues
475
+ issues = parsed.get("issues", [])
476
+ if issues:
477
+ lines.extend(["## Issues", ""])
478
+ for issue in issues:
479
+ lines.append(f"- {issue}")
480
+ lines.append("")
481
+
482
+ # Recommendations
483
+ recommendations = parsed.get("recommendations", [])
484
+ if recommendations:
485
+ lines.extend(["## Recommendations", ""])
486
+ for rec in recommendations:
487
+ lines.append(f"- {rec}")
488
+ lines.append("")
489
+
490
+ # Verdict consensus (if synthesized)
491
+ verdict_consensus = parsed.get("verdict_consensus", {})
492
+ if verdict_consensus:
493
+ lines.extend(["## Verdict Consensus", ""])
494
+ votes = verdict_consensus.get("votes", {})
495
+ for verdict_type, models in votes.items():
496
+ if models:
497
+ lines.append(f"- **{verdict_type}:** {', '.join(models)}")
498
+ agreement = verdict_consensus.get("agreement_level", "")
499
+ if agreement:
500
+ lines.append(f"\n**Agreement Level:** {agreement}")
501
+ notes = verdict_consensus.get("notes", "")
502
+ if notes:
503
+ lines.extend(["", notes])
504
+ lines.append("")
505
+
506
+ # Synthesis metadata
507
+ synth_meta = parsed.get("synthesis_metadata", {})
508
+ if synth_meta:
509
+ lines.extend(["## Synthesis Metadata", ""])
510
+ if synth_meta.get("models_consulted"):
511
+ lines.append(f"- Models consulted: {', '.join(synth_meta['models_consulted'])}")
512
+ if synth_meta.get("models_succeeded"):
513
+ lines.append(f"- Models succeeded: {', '.join(synth_meta['models_succeeded'])}")
514
+ if synth_meta.get("synthesis_provider"):
515
+ lines.append(f"- Synthesis provider: {synth_meta['synthesis_provider']}")
516
+ lines.append("")
517
+
518
+ lines.extend([
519
+ "---",
520
+ "*Generated by Foundry MCP Fidelity Review*",
521
+ ])
522
+
523
+ return "\n".join(lines)
524
+
525
+
363
526
  def _handle_fidelity(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
364
527
  """Best-effort fidelity review.
365
528
 
@@ -512,9 +675,25 @@ def _handle_fidelity(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
512
675
 
513
676
  scope = "task" if task_id else ("phase" if phase_id else "spec")
514
677
 
678
+ # Setup fidelity reviews directory and file naming
679
+ fidelity_reviews_dir = Path(specs_dir) / ".fidelity-reviews"
680
+ base_name = f"{spec_id}-{scope}"
681
+ if task_id:
682
+ base_name += f"-{task_id}"
683
+ elif phase_id:
684
+ base_name += f"-{phase_id}"
685
+ provider_review_paths: List[Dict[str, Any]] = []
686
+ review_path: Optional[str] = None
687
+
515
688
  spec_requirements = _build_spec_requirements(spec_data, task_id, phase_id)
516
689
  implementation_artifacts = _build_implementation_artifacts(
517
- spec_data, task_id, phase_id, files, incremental, base_branch
690
+ spec_data,
691
+ task_id,
692
+ phase_id,
693
+ files,
694
+ incremental,
695
+ base_branch,
696
+ workspace_root=ws_path,
518
697
  )
519
698
  test_results = (
520
699
  _build_test_results(spec_data, task_id, phase_id) if include_tests else ""
@@ -555,27 +734,202 @@ def _handle_fidelity(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
555
734
 
556
735
  result = orchestrator.consult(request, use_cache=True)
557
736
  is_consensus = isinstance(result, ConsensusResult)
558
- content = result.primary_content if is_consensus else result.content
737
+ synthesis_performed = False
738
+ synthesis_error = None
739
+ successful_providers: List[str] = []
740
+ failed_providers: List[Dict[str, Any]] = []
741
+
742
+ if is_consensus:
743
+ # Extract provider details for visibility
744
+ failed_providers = [
745
+ {"provider_id": r.provider_id, "error": r.error}
746
+ for r in result.responses
747
+ if not r.success
748
+ ]
749
+ # Filter for truly successful responses (success=True AND non-empty content)
750
+ successful_responses = [
751
+ r for r in result.responses if r.success and r.content.strip()
752
+ ]
753
+ successful_providers = [r.provider_id for r in successful_responses]
754
+
755
+ if len(successful_responses) >= 2:
756
+ # Multi-model mode: run synthesis to consolidate reviews
757
+ model_reviews_json = ""
758
+ for response in successful_responses:
759
+ model_reviews_json += (
760
+ f"\n---\n## Review by {response.provider_id}\n\n"
761
+ f"```json\n{response.content}\n```\n"
762
+ )
763
+
764
+ # Write individual provider review files
765
+ try:
766
+ fidelity_reviews_dir.mkdir(parents=True, exist_ok=True)
767
+ for response in successful_responses:
768
+ provider_parsed = _parse_json_content(response.content)
769
+ provider_file = fidelity_reviews_dir / f"{base_name}-{response.provider_id}.md"
770
+ if provider_parsed:
771
+ provider_md = _format_fidelity_markdown(
772
+ provider_parsed,
773
+ spec_id,
774
+ spec_data.get("title", spec_id),
775
+ scope,
776
+ task_id=task_id,
777
+ phase_id=phase_id,
778
+ provider_id=response.provider_id,
779
+ )
780
+ provider_file.write_text(provider_md, encoding="utf-8")
781
+ provider_review_paths.append({
782
+ "provider_id": response.provider_id,
783
+ "path": str(provider_file),
784
+ })
785
+ else:
786
+ # JSON parsing failed - write raw content as fallback
787
+ logger.warning(
788
+ "Provider %s returned non-JSON content, writing raw response",
789
+ response.provider_id,
790
+ )
791
+ raw_md = (
792
+ f"# Fidelity Review (Raw): {spec_id}\n\n"
793
+ f"**Provider:** {response.provider_id}\n"
794
+ f"**Note:** Response could not be parsed as JSON\n\n"
795
+ f"## Raw Response\n\n```\n{response.content}\n```\n"
796
+ )
797
+ provider_file.write_text(raw_md, encoding="utf-8")
798
+ provider_review_paths.append({
799
+ "provider_id": response.provider_id,
800
+ "path": str(provider_file),
801
+ "parse_error": True,
802
+ })
803
+ except Exception as e:
804
+ logger.warning("Failed to write provider review files: %s", e)
805
+
806
+ logger.info(
807
+ "Running fidelity synthesis for %d provider reviews: %s",
808
+ len(successful_responses),
809
+ successful_providers,
810
+ )
811
+
812
+ synthesis_request = ConsultationRequest(
813
+ workflow=ConsultationWorkflow.FIDELITY_REVIEW,
814
+ prompt_id="FIDELITY_SYNTHESIS_PROMPT_V1",
815
+ context={
816
+ "spec_id": spec_id,
817
+ "spec_title": spec_data.get("title", spec_id),
818
+ "review_scope": scope,
819
+ "num_models": len(successful_responses),
820
+ "model_reviews": model_reviews_json,
821
+ "response_schema": FIDELITY_SYNTHESIZED_RESPONSE_SCHEMA,
822
+ },
823
+ provider_id=successful_providers[0],
824
+ model=model,
825
+ )
826
+
827
+ try:
828
+ synthesis_result = orchestrator.consult(synthesis_request, use_cache=True)
829
+ except Exception as e:
830
+ logger.error("Fidelity synthesis call crashed: %s", e, exc_info=True)
831
+ synthesis_result = None
832
+
833
+ # Handle both ConsultationResult and ConsensusResult from synthesis
834
+ synthesis_success = False
835
+ synthesis_content = None
836
+ if synthesis_result:
837
+ if isinstance(synthesis_result, ConsultationResult) and synthesis_result.success:
838
+ synthesis_content = synthesis_result.content
839
+ synthesis_success = bool(synthesis_content and synthesis_content.strip())
840
+ elif isinstance(synthesis_result, ConsensusResult) and synthesis_result.success:
841
+ synthesis_content = synthesis_result.primary_content
842
+ synthesis_success = bool(synthesis_content and synthesis_content.strip())
843
+
844
+ if synthesis_success and synthesis_content:
845
+ content = synthesis_content
846
+ synthesis_performed = True
847
+ else:
848
+ # Synthesis failed - fall back to first provider's content
849
+ error_detail = "unknown"
850
+ if synthesis_result is None:
851
+ error_detail = "synthesis crashed (see logs)"
852
+ elif isinstance(synthesis_result, ConsultationResult):
853
+ error_detail = synthesis_result.error or "empty response"
854
+ elif isinstance(synthesis_result, ConsensusResult):
855
+ error_detail = "empty synthesis content"
856
+ logger.warning(
857
+ "Fidelity synthesis call failed (%s), falling back to first provider's content",
858
+ error_detail,
859
+ )
860
+ content = result.primary_content
861
+ synthesis_error = error_detail
862
+ else:
863
+ # Single successful provider - use its content directly (no synthesis needed)
864
+ content = result.primary_content
865
+ else:
866
+ content = result.content
559
867
 
560
868
  parsed = _parse_json_content(content)
561
869
  verdict = parsed.get("verdict") if parsed else "unknown"
562
870
 
871
+ # Write main fidelity review file
872
+ if parsed:
873
+ try:
874
+ fidelity_reviews_dir.mkdir(parents=True, exist_ok=True)
875
+ main_md = _format_fidelity_markdown(
876
+ parsed,
877
+ spec_id,
878
+ spec_data.get("title", spec_id),
879
+ scope,
880
+ task_id=task_id,
881
+ phase_id=phase_id,
882
+ )
883
+ review_file = fidelity_reviews_dir / f"{base_name}.md"
884
+ review_file.write_text(main_md, encoding="utf-8")
885
+ review_path = str(review_file)
886
+ except Exception as e:
887
+ logger.warning("Failed to write main fidelity review file: %s", e)
888
+
563
889
  duration_ms = (time.perf_counter() - start_time) * 1000
564
890
 
891
+ # Build consensus info with synthesis details
892
+ consensus_info: Dict[str, Any] = {
893
+ "mode": "multi_model" if is_consensus else "single_model",
894
+ "threshold": consensus_threshold,
895
+ "provider_id": getattr(result, "provider_id", None),
896
+ "model_used": getattr(result, "model_used", None),
897
+ "synthesis_performed": synthesis_performed,
898
+ }
899
+
900
+ if is_consensus:
901
+ consensus_info["successful_providers"] = successful_providers
902
+ consensus_info["failed_providers"] = failed_providers
903
+ if synthesis_error:
904
+ consensus_info["synthesis_error"] = synthesis_error
905
+
906
+ # Include additional synthesized fields if available
907
+ response_data: Dict[str, Any] = {
908
+ "spec_id": spec_id,
909
+ "title": spec_data.get("title", spec_id),
910
+ "scope": scope,
911
+ "verdict": verdict,
912
+ "deviations": parsed.get("deviations") if parsed else [],
913
+ "recommendations": parsed.get("recommendations") if parsed else [],
914
+ "consensus": consensus_info,
915
+ }
916
+
917
+ # Add file paths if reviews were written
918
+ if review_path:
919
+ response_data["review_path"] = review_path
920
+ if provider_review_paths:
921
+ response_data["provider_reviews"] = provider_review_paths
922
+
923
+ # Add synthesis-specific fields if synthesis was performed
924
+ if synthesis_performed and parsed:
925
+ if "verdict_consensus" in parsed:
926
+ response_data["verdict_consensus"] = parsed["verdict_consensus"]
927
+ if "synthesis_metadata" in parsed:
928
+ response_data["synthesis_metadata"] = parsed["synthesis_metadata"]
929
+
565
930
  return asdict(
566
931
  success_response(
567
- spec_id=spec_id,
568
- title=spec_data.get("title", spec_id),
569
- scope=scope,
570
- verdict=verdict,
571
- deviations=(parsed.get("deviations") if parsed else []),
572
- recommendations=(parsed.get("recommendations") if parsed else []),
573
- consensus={
574
- "mode": "multi_model" if is_consensus else "single_model",
575
- "threshold": consensus_threshold,
576
- "provider_id": getattr(result, "provider_id", None),
577
- "model_used": getattr(result, "model_used", None),
578
- },
932
+ **response_data,
579
933
  telemetry={"duration_ms": round(duration_ms, 2)},
580
934
  )
581
935
  )
@@ -633,7 +987,7 @@ def register_unified_review_tool(mcp: FastMCP, config: ServerConfig) -> None:
633
987
  def review(
634
988
  action: str,
635
989
  spec_id: Optional[str] = None,
636
- review_type: str = "quick",
990
+ review_type: Optional[str] = None,
637
991
  tools: Optional[str] = None,
638
992
  model: Optional[str] = None,
639
993
  ai_provider: Optional[str] = None,