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
@@ -30,17 +30,32 @@ from foundry_mcp.core.responses import (
30
30
  success_response,
31
31
  )
32
32
  from foundry_mcp.core.spec import (
33
+ TEMPLATES,
34
+ TEMPLATE_DESCRIPTIONS,
35
+ check_spec_completeness,
36
+ detect_duplicate_tasks,
37
+ diff_specs,
33
38
  find_spec_file,
34
39
  find_specs_directory,
40
+ list_spec_backups,
35
41
  list_specs,
36
42
  load_spec,
37
43
  )
38
44
  from foundry_mcp.core.validation import (
45
+ VALID_NODE_TYPES,
46
+ VALID_STATUSES,
47
+ VALID_TASK_CATEGORIES,
48
+ VALID_VERIFICATION_TYPES,
39
49
  apply_fixes,
40
50
  calculate_stats,
41
51
  get_fix_actions,
42
52
  validate_spec,
43
53
  )
54
+ from foundry_mcp.core.journal import (
55
+ VALID_BLOCKER_TYPES,
56
+ VALID_ENTRY_TYPES,
57
+ )
58
+ from foundry_mcp.core.lifecycle import VALID_FOLDERS
44
59
  from foundry_mcp.tools.unified.router import (
45
60
  ActionDefinition,
46
61
  ActionRouter,
@@ -102,6 +117,52 @@ def _handle_find(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
102
117
  return asdict(success_response(found=False, spec_id=spec_id))
103
118
 
104
119
 
120
+ def _handle_get(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
121
+ """Return raw spec JSON content in minified form."""
122
+ import json as _json
123
+
124
+ spec_id = payload.get("spec_id")
125
+ workspace = payload.get("workspace")
126
+
127
+ if not isinstance(spec_id, str) or not spec_id.strip():
128
+ return asdict(
129
+ error_response(
130
+ "spec_id is required",
131
+ error_code=ErrorCode.MISSING_REQUIRED,
132
+ error_type=ErrorType.VALIDATION,
133
+ remediation="Provide a spec_id parameter",
134
+ )
135
+ )
136
+
137
+ specs_dir = _resolve_specs_dir(config, workspace)
138
+ if not specs_dir:
139
+ return asdict(
140
+ error_response(
141
+ "No specs directory found",
142
+ error_code=ErrorCode.NOT_FOUND,
143
+ error_type=ErrorType.NOT_FOUND,
144
+ remediation="Ensure you're in a project with a specs/ directory or pass workspace.",
145
+ details={"workspace": workspace},
146
+ )
147
+ )
148
+
149
+ spec_data = load_spec(spec_id, specs_dir)
150
+ if spec_data is None:
151
+ return asdict(
152
+ error_response(
153
+ f"Spec not found: {spec_id}",
154
+ error_code=ErrorCode.NOT_FOUND,
155
+ error_type=ErrorType.NOT_FOUND,
156
+ remediation=f"Verify the spec_id exists. Use spec(action='list') to see available specs.",
157
+ details={"spec_id": spec_id},
158
+ )
159
+ )
160
+
161
+ # Return minified JSON string to minimize token usage
162
+ minified_spec = _json.dumps(spec_data, separators=(",", ":"))
163
+ return asdict(success_response(spec=minified_spec))
164
+
165
+
105
166
  def _handle_list(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
106
167
  status = payload.get("status", "all")
107
168
  include_progress = payload.get("include_progress", True)
@@ -723,8 +784,287 @@ def _handle_analyze_deps(*, config: ServerConfig, payload: Dict[str, Any]) -> di
723
784
  )
724
785
 
725
786
 
787
+ def _handle_schema(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
788
+ """Return schema information for all valid values in the spec system."""
789
+ # Build templates with descriptions
790
+ templates_with_desc = [
791
+ {"name": t, "description": TEMPLATE_DESCRIPTIONS.get(t, "")}
792
+ for t in TEMPLATES
793
+ ]
794
+ return asdict(
795
+ success_response(
796
+ templates=templates_with_desc,
797
+ node_types=sorted(VALID_NODE_TYPES),
798
+ statuses=sorted(VALID_STATUSES),
799
+ task_categories=sorted(VALID_TASK_CATEGORIES),
800
+ verification_types=sorted(VALID_VERIFICATION_TYPES),
801
+ journal_entry_types=sorted(VALID_ENTRY_TYPES),
802
+ blocker_types=sorted(VALID_BLOCKER_TYPES),
803
+ status_folders=sorted(VALID_FOLDERS),
804
+ )
805
+ )
806
+
807
+
808
+ def _handle_diff(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
809
+ """Compare two specs and return categorized changes."""
810
+ spec_id = payload.get("spec_id")
811
+ if not spec_id:
812
+ return asdict(
813
+ error_response(
814
+ "spec_id is required for diff action",
815
+ error_code=ErrorCode.MISSING_REQUIRED,
816
+ error_type=ErrorType.VALIDATION,
817
+ remediation="Provide the spec_id of the current spec to compare",
818
+ )
819
+ )
820
+
821
+ # Target can be a backup timestamp or another spec_id
822
+ target = payload.get("target")
823
+ workspace = payload.get("workspace")
824
+ max_results = payload.get("limit")
825
+
826
+ specs_dir = _resolve_specs_dir(config, workspace)
827
+ if not specs_dir:
828
+ return asdict(
829
+ error_response(
830
+ "No specs directory found",
831
+ error_code=ErrorCode.NOT_FOUND,
832
+ error_type=ErrorType.NOT_FOUND,
833
+ remediation="Ensure you're in a project with a specs/ directory",
834
+ )
835
+ )
836
+
837
+ # If no target specified, diff against latest backup
838
+ if not target:
839
+ backups = list_spec_backups(spec_id, specs_dir=specs_dir)
840
+ if backups["count"] == 0:
841
+ return asdict(
842
+ error_response(
843
+ f"No backups found for spec '{spec_id}'",
844
+ error_code=ErrorCode.NOT_FOUND,
845
+ error_type=ErrorType.NOT_FOUND,
846
+ remediation="Create a backup first using spec save operations",
847
+ )
848
+ )
849
+ # Use latest backup as source (older state)
850
+ source_path = backups["backups"][0]["file_path"]
851
+ else:
852
+ # Check if target is a timestamp (backup) or spec_id
853
+ backup_file = specs_dir / ".backups" / spec_id / f"{target}.json"
854
+ if backup_file.is_file():
855
+ source_path = str(backup_file)
856
+ else:
857
+ # Treat as another spec_id
858
+ source_path = target
859
+
860
+ result = diff_specs(
861
+ source=source_path,
862
+ target=spec_id,
863
+ specs_dir=specs_dir,
864
+ max_results=max_results,
865
+ )
866
+
867
+ if "error" in result and not result.get("success", True):
868
+ return asdict(
869
+ error_response(
870
+ result["error"],
871
+ error_code=ErrorCode.NOT_FOUND,
872
+ error_type=ErrorType.NOT_FOUND,
873
+ remediation="Verify both specs exist and are accessible",
874
+ )
875
+ )
876
+
877
+ return asdict(
878
+ success_response(
879
+ spec_id=spec_id,
880
+ compared_to=source_path if not target else target,
881
+ summary=result["summary"],
882
+ changes=result["changes"],
883
+ partial=result["partial"],
884
+ )
885
+ )
886
+
887
+
888
+ def _handle_history(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
889
+ """List spec history including backups and revision history."""
890
+ spec_id = payload.get("spec_id")
891
+ if not spec_id:
892
+ return asdict(
893
+ error_response(
894
+ "spec_id is required for history action",
895
+ error_code=ErrorCode.MISSING_REQUIRED,
896
+ error_type=ErrorType.VALIDATION,
897
+ remediation="Provide the spec_id to view history",
898
+ )
899
+ )
900
+
901
+ workspace = payload.get("workspace")
902
+ cursor = payload.get("cursor")
903
+ limit = payload.get("limit")
904
+
905
+ specs_dir = _resolve_specs_dir(config, workspace)
906
+ if not specs_dir:
907
+ return asdict(
908
+ error_response(
909
+ "No specs directory found",
910
+ error_code=ErrorCode.NOT_FOUND,
911
+ error_type=ErrorType.NOT_FOUND,
912
+ remediation="Ensure you're in a project with a specs/ directory",
913
+ )
914
+ )
915
+
916
+ # Get backups with pagination
917
+ backups_result = list_spec_backups(
918
+ spec_id, specs_dir=specs_dir, cursor=cursor, limit=limit
919
+ )
920
+
921
+ # Get revision history from spec metadata
922
+ spec_data = load_spec(spec_id, specs_dir)
923
+ revision_history = []
924
+ if spec_data:
925
+ metadata = spec_data.get("metadata", {})
926
+ revision_history = metadata.get("revision_history", [])
927
+
928
+ # Merge and sort entries (backups and revisions)
929
+ history_entries = []
930
+
931
+ # Add backups as history entries
932
+ for backup in backups_result["backups"]:
933
+ history_entries.append({
934
+ "type": "backup",
935
+ "timestamp": backup["timestamp"],
936
+ "file_path": backup["file_path"],
937
+ "file_size_bytes": backup["file_size_bytes"],
938
+ })
939
+
940
+ # Add revision history entries
941
+ for rev in revision_history:
942
+ history_entries.append({
943
+ "type": "revision",
944
+ "timestamp": rev.get("date"),
945
+ "version": rev.get("version"),
946
+ "changes": rev.get("changes"),
947
+ "author": rev.get("author"),
948
+ })
949
+
950
+ return asdict(
951
+ success_response(
952
+ spec_id=spec_id,
953
+ entries=history_entries,
954
+ backup_count=backups_result["count"],
955
+ revision_count=len(revision_history),
956
+ pagination=backups_result["pagination"],
957
+ )
958
+ )
959
+
960
+
961
+ def _handle_completeness_check(
962
+ *, config: ServerConfig, payload: Dict[str, Any]
963
+ ) -> dict:
964
+ """Check spec completeness and return a score (0-100)."""
965
+ spec_id = payload.get("spec_id")
966
+ if not spec_id or not isinstance(spec_id, str) or not spec_id.strip():
967
+ return asdict(
968
+ error_response(
969
+ "spec_id is required for completeness-check action",
970
+ error_code=ErrorCode.MISSING_REQUIRED,
971
+ error_type=ErrorType.VALIDATION,
972
+ remediation="Provide the spec_id to check completeness",
973
+ )
974
+ )
975
+
976
+ workspace = payload.get("workspace")
977
+ specs_dir = _resolve_specs_dir(config, workspace)
978
+ if not specs_dir:
979
+ return asdict(
980
+ error_response(
981
+ "No specs directory found",
982
+ error_code=ErrorCode.NOT_FOUND,
983
+ error_type=ErrorType.NOT_FOUND,
984
+ remediation="Ensure you're in a project with a specs/ directory",
985
+ )
986
+ )
987
+
988
+ result, error = check_spec_completeness(spec_id, specs_dir=specs_dir)
989
+ if error:
990
+ return asdict(
991
+ error_response(
992
+ error,
993
+ error_code=ErrorCode.SPEC_NOT_FOUND,
994
+ error_type=ErrorType.NOT_FOUND,
995
+ remediation='Verify the spec ID exists using spec(action="list").',
996
+ details={"spec_id": spec_id},
997
+ )
998
+ )
999
+
1000
+ return asdict(success_response(**result))
1001
+
1002
+
1003
+ def _handle_duplicate_detection(
1004
+ *, config: ServerConfig, payload: Dict[str, Any]
1005
+ ) -> dict:
1006
+ """Detect duplicate or near-duplicate tasks in a spec."""
1007
+ spec_id = payload.get("spec_id")
1008
+ if not spec_id or not isinstance(spec_id, str) or not spec_id.strip():
1009
+ return asdict(
1010
+ error_response(
1011
+ "spec_id is required for duplicate-detection action",
1012
+ error_code=ErrorCode.MISSING_REQUIRED,
1013
+ error_type=ErrorType.VALIDATION,
1014
+ remediation="Provide the spec_id to check for duplicates",
1015
+ )
1016
+ )
1017
+
1018
+ workspace = payload.get("workspace")
1019
+ scope = payload.get("scope", "titles")
1020
+ threshold = payload.get("threshold", 0.8)
1021
+ max_pairs = payload.get("max_pairs", 100)
1022
+
1023
+ # Validate threshold
1024
+ if not isinstance(threshold, (int, float)) or not 0.0 <= threshold <= 1.0:
1025
+ return asdict(
1026
+ error_response(
1027
+ "threshold must be a number between 0.0 and 1.0",
1028
+ error_code=ErrorCode.VALIDATION_ERROR,
1029
+ error_type=ErrorType.VALIDATION,
1030
+ )
1031
+ )
1032
+
1033
+ specs_dir = _resolve_specs_dir(config, workspace)
1034
+ if not specs_dir:
1035
+ return asdict(
1036
+ error_response(
1037
+ "No specs directory found",
1038
+ error_code=ErrorCode.NOT_FOUND,
1039
+ error_type=ErrorType.NOT_FOUND,
1040
+ remediation="Ensure you're in a project with a specs/ directory",
1041
+ )
1042
+ )
1043
+
1044
+ result, error = detect_duplicate_tasks(
1045
+ spec_id,
1046
+ scope=scope,
1047
+ threshold=threshold,
1048
+ max_pairs=max_pairs,
1049
+ specs_dir=specs_dir,
1050
+ )
1051
+ if error:
1052
+ return asdict(
1053
+ error_response(
1054
+ error,
1055
+ error_code=ErrorCode.SPEC_NOT_FOUND,
1056
+ error_type=ErrorType.NOT_FOUND,
1057
+ remediation='Verify the spec ID exists using spec(action="list").',
1058
+ details={"spec_id": spec_id},
1059
+ )
1060
+ )
1061
+
1062
+ return asdict(success_response(**result))
1063
+
1064
+
726
1065
  _ACTIONS = [
727
1066
  ActionDefinition(name="find", handler=_handle_find, summary="Find a spec by ID"),
1067
+ ActionDefinition(name="get", handler=_handle_get, summary="Get raw spec JSON (minified)"),
728
1068
  ActionDefinition(name="list", handler=_handle_list, summary="List specs"),
729
1069
  ActionDefinition(
730
1070
  name="validate", handler=_handle_validate, summary="Validate a spec"
@@ -744,6 +1084,31 @@ _ACTIONS = [
744
1084
  handler=_handle_analyze_deps,
745
1085
  summary="Analyze spec dependency graph",
746
1086
  ),
1087
+ ActionDefinition(
1088
+ name="schema",
1089
+ handler=_handle_schema,
1090
+ summary="Get valid values for spec fields",
1091
+ ),
1092
+ ActionDefinition(
1093
+ name="diff",
1094
+ handler=_handle_diff,
1095
+ summary="Compare spec against backup or another spec",
1096
+ ),
1097
+ ActionDefinition(
1098
+ name="history",
1099
+ handler=_handle_history,
1100
+ summary="List spec backups and revision history",
1101
+ ),
1102
+ ActionDefinition(
1103
+ name="completeness-check",
1104
+ handler=_handle_completeness_check,
1105
+ summary="Check spec completeness and return a score (0-100)",
1106
+ ),
1107
+ ActionDefinition(
1108
+ name="duplicate-detection",
1109
+ handler=_handle_duplicate_detection,
1110
+ summary="Detect duplicate or near-duplicate tasks",
1111
+ ),
747
1112
  ]
748
1113
 
749
1114
  _SPEC_ROUTER = ActionRouter(tool_name="spec", actions=_ACTIONS)
@@ -785,6 +1150,7 @@ def register_unified_spec_tool(mcp: FastMCP, config: ServerConfig) -> None:
785
1150
  directory: Optional[str] = None,
786
1151
  path: Optional[str] = None,
787
1152
  bottleneck_threshold: Optional[int] = None,
1153
+ target: Optional[str] = None,
788
1154
  ) -> dict:
789
1155
  payload = {
790
1156
  "spec_id": spec_id,
@@ -799,6 +1165,7 @@ def register_unified_spec_tool(mcp: FastMCP, config: ServerConfig) -> None:
799
1165
  "directory": directory,
800
1166
  "path": path,
801
1167
  "bottleneck_threshold": bottleneck_threshold,
1168
+ "target": target,
802
1169
  }
803
1170
  return _dispatch_spec_action(action=action, payload=payload, config=config)
804
1171