|
9 | 9 | - Is private, with main as default branch |
10 | 10 |
|
11 | 11 | Usage: |
12 | | - python scripts/create_mcp_repos.py |
| 12 | + python scripts/tools/create_mcp_repos.py --dry-run # report only, write nothing |
| 13 | + python scripts/tools/create_mcp_repos.py # bootstrap missing repos only |
| 14 | + python scripts/tools/create_mcp_repos.py --force # also overwrite existing repos |
| 15 | +
|
| 16 | +Safety: |
| 17 | + This script was written as a one-time bootstrap and is destructive when re-run. |
| 18 | + Writing into an existing repo replaces server.py and base/, rewrites the |
| 19 | + generated docs, commits the result as "initial commit", and pushes it to |
| 20 | + origin. History survives (it is an ordinary commit on top, not a force-push) |
| 21 | + but HEAD moves to the stale generated version. |
| 22 | +
|
| 23 | + Existing repos are therefore skipped unless --force is passed, and repos in |
| 24 | + CANONICAL_REPOS are never written to at all. See _guard_existing_repo. |
13 | 25 |
|
14 | 26 | Prerequisites: |
15 | 27 | - gh CLI authenticated (gh auth status) |
16 | 28 | - Git configured with user name/email |
17 | 29 | """ |
18 | 30 |
|
| 31 | +import argparse |
19 | 32 | import shlex |
20 | 33 | import shutil |
21 | 34 | import subprocess |
@@ -440,6 +453,23 @@ def display_path(*parts): |
440 | 453 | # -- Shared utility files to copy into each server repo ----------------------- |
441 | 454 | SHARED_UTILS = ["mcp_errors.py", "input_validator.py", "rate_limiter.py"] |
442 | 455 |
|
| 456 | +# -- Regeneration safety ------------------------------------------------------- |
| 457 | +# Measured 2026-08-10: all 13 target repos have evolved past their generated |
| 458 | +# state -- 75 commits and roughly 29 merged PRs between them. Regeneration is |
| 459 | +# therefore a destructive operation against real work, not a refresh. |
| 460 | +# |
| 461 | +# CANONICAL_REPOS are never written to, with or without --force, because the |
| 462 | +# repo is ahead of the source this script would copy from. Removing a name from |
| 463 | +# this set means asserting that src/mcp/ is once again the better version. |
| 464 | +CANONICAL_REPOS = { |
| 465 | + # Authority for the session MCP surface. It is what settings.json runs, and |
| 466 | + # it leads the in-engine copy by mcp 2.x support, an O_CREAT|O_EXCL |
| 467 | + # claim-file mutex, and a merged path-traversal fix. Regenerating it from |
| 468 | + # src/mcp/session_mcp_server.py would silently revert all three. |
| 469 | + # See docs/policies/session-management-policy.md. |
| 470 | + "mcp-session-mgr", |
| 471 | +} |
| 472 | + |
443 | 473 | # -- .gitignore template ------------------------------------------------------- |
444 | 474 | GITIGNORE = """__pycache__/ |
445 | 475 | *.py[cod] |
@@ -758,14 +788,90 @@ def write_file(path, content): |
758 | 788 | path.write_text(content, encoding="utf-8") |
759 | 789 |
|
760 | 790 |
|
| 791 | +def _commit_count(repo_dir): |
| 792 | + """Count commits on the current branch of a repo. |
| 793 | +
|
| 794 | + Args: |
| 795 | + repo_dir: Path to a directory that may or may not be a git repo. |
| 796 | +
|
| 797 | + Returns: |
| 798 | + int: Commit count, or 0 when the directory is not a readable git repo. |
| 799 | + """ |
| 800 | + result = subprocess.run( |
| 801 | + ["git", "rev-list", "--count", "HEAD"], |
| 802 | + shell=False, |
| 803 | + cwd=str(repo_dir), |
| 804 | + capture_output=True, |
| 805 | + text=True, |
| 806 | + ) |
| 807 | + if result.returncode != 0: |
| 808 | + return 0 |
| 809 | + try: |
| 810 | + return int(result.stdout.strip()) |
| 811 | + except ValueError: |
| 812 | + return 0 |
| 813 | + |
| 814 | + |
| 815 | +def _guard_existing_repo(repo_name, repo_dir, force): |
| 816 | + """Decide whether this run may write into a target repo. |
| 817 | +
|
| 818 | + Bootstrapping a repo that does not exist yet is always safe. Writing into |
| 819 | + one that does is not: it replaces server.py and base/, rewrites the |
| 820 | + generated docs, commits the result, and pushes it. The default is therefore |
| 821 | + to refuse, so that a routine run cannot silently revert downstream work. |
| 822 | +
|
| 823 | + Args: |
| 824 | + repo_name: Repo name, matched against CANONICAL_REPOS. |
| 825 | + repo_dir: Path the repo would be written to. |
| 826 | + force: True when the caller passed --force. |
| 827 | +
|
| 828 | + Returns: |
| 829 | + bool: True when the caller may proceed, False when it must skip. |
| 830 | + """ |
| 831 | + if repo_name in CANONICAL_REPOS: |
| 832 | + print(f" REFUSED: {repo_name} is canonical -- it leads src/mcp/ and is never regenerated.") |
| 833 | + print(" Remove it from CANONICAL_REPOS only if src/mcp/ is genuinely ahead.") |
| 834 | + return False |
| 835 | + |
| 836 | + if not repo_dir.exists(): |
| 837 | + return True |
| 838 | + |
| 839 | + commits = _commit_count(repo_dir) |
| 840 | + if not force: |
| 841 | + print(f" REFUSED: {repo_dir.name} already exists ({commits} commits).") |
| 842 | + print(" Overwriting replaces server.py and base/, then commits and pushes.") |
| 843 | + print(" Pass --force if you intend to discard what is there.") |
| 844 | + return False |
| 845 | + |
| 846 | + print(f" FORCED: overwriting existing {repo_dir.name} ({commits} commits will be superseded).") |
| 847 | + return True |
| 848 | + |
| 849 | + |
761 | 850 | # -- Main ---------------------------------------------------------------------- |
762 | | -def create_repo(server): |
| 851 | +def create_repo(server, force=False, dry_run=False): |
| 852 | + """Generate one MCP server repo, refusing to overwrite existing work. |
| 853 | +
|
| 854 | + Args: |
| 855 | + server: Entry from SERVERS describing the repo to generate. |
| 856 | + force: Allow writing into a repo that already exists. |
| 857 | + dry_run: Report the decision and write nothing. |
| 858 | +
|
| 859 | + Returns: |
| 860 | + bool: True when the repo was written, False when it was skipped. |
| 861 | + """ |
763 | 862 | repo_name = server["repo"] |
764 | 863 | repo_dir = WORKSPACE / repo_name |
765 | 864 | print(f"\n{'='*60}") |
766 | 865 | print(f"Creating: {repo_name}") |
767 | 866 | print(f"{'='*60}") |
768 | 867 |
|
| 868 | + if not _guard_existing_repo(repo_name, repo_dir, force): |
| 869 | + return False |
| 870 | + |
| 871 | + if dry_run: |
| 872 | + print(f" DRY RUN: would generate into {repo_dir}") |
| 873 | + return False |
| 874 | + |
769 | 875 | # 1. Create directory |
770 | 876 | if repo_dir.exists(): |
771 | 877 | print(" Directory already exists, skipping mkdir") |
@@ -841,14 +947,29 @@ def create_repo(server): |
841 | 947 | return True |
842 | 948 |
|
843 | 949 |
|
844 | | -def create_mcp_base_repo(): |
845 | | - """Create the shared mcp-base repo.""" |
| 950 | +def create_mcp_base_repo(force=False, dry_run=False): |
| 951 | + """Create the shared mcp-base repo, refusing to overwrite existing work. |
| 952 | +
|
| 953 | + Args: |
| 954 | + force: Allow writing into a repo that already exists. |
| 955 | + dry_run: Report the decision and write nothing. |
| 956 | +
|
| 957 | + Returns: |
| 958 | + bool: True when the repo was written, False when it was skipped. |
| 959 | + """ |
846 | 960 | repo_name = "mcp-base" |
847 | 961 | repo_dir = WORKSPACE / repo_name |
848 | 962 | print(f"\n{'='*60}") |
849 | 963 | print(f"Creating: {repo_name} (shared base package)") |
850 | 964 | print(f"{'='*60}") |
851 | 965 |
|
| 966 | + if not _guard_existing_repo(repo_name, repo_dir, force): |
| 967 | + return False |
| 968 | + |
| 969 | + if dry_run: |
| 970 | + print(f" DRY RUN: would generate into {repo_dir}") |
| 971 | + return False |
| 972 | + |
852 | 973 | repo_dir.mkdir(parents=True, exist_ok=True) |
853 | 974 |
|
854 | 975 | # Copy base/ package as mcp_base/ package |
@@ -1028,38 +1149,76 @@ def create_mcp_base_repo(): |
1028 | 1149 |
|
1029 | 1150 |
|
1030 | 1151 | def main(): |
| 1152 | + """Generate the MCP server repos, skipping any that already exist. |
| 1153 | +
|
| 1154 | + Returns: |
| 1155 | + int: 0 when every target was written or the run was a dry run, 1 when |
| 1156 | + any target was refused or failed. A plain run against an already |
| 1157 | + bootstrapped workspace refuses everything and exits 1 on purpose, |
| 1158 | + so that re-running this script cannot look like a success. |
| 1159 | + """ |
| 1160 | + parser = argparse.ArgumentParser(description="Create the techdeveloper-org MCP server repos.") |
| 1161 | + parser.add_argument( |
| 1162 | + "--force", |
| 1163 | + action="store_true", |
| 1164 | + help="Overwrite repos that already exist. Discards downstream work; see the module docstring.", |
| 1165 | + ) |
| 1166 | + parser.add_argument( |
| 1167 | + "--dry-run", |
| 1168 | + action="store_true", |
| 1169 | + help="Report what each target would do and write nothing.", |
| 1170 | + ) |
| 1171 | + args = parser.parse_args() |
| 1172 | + |
1031 | 1173 | print("Claude Workflow Engine -- MCP Repos Creator") |
1032 | 1174 | print(f"Organization: {ORG}") |
1033 | 1175 | print(f"Workspace: {WORKSPACE}") |
1034 | | - print(f"Total repos to create: {len(SERVERS) + 1} (1 base + {len(SERVERS)} servers)") |
1035 | | - |
1036 | | - # Create mcp-base first |
1037 | | - create_mcp_base_repo() |
| 1176 | + print(f"Targets: {len(SERVERS) + 1} (1 base + {len(SERVERS)} servers)") |
| 1177 | + if args.dry_run: |
| 1178 | + print("Mode: DRY RUN -- nothing will be written") |
| 1179 | + elif args.force: |
| 1180 | + print("Mode: FORCE -- existing repos will be overwritten, committed and pushed") |
| 1181 | + else: |
| 1182 | + print("Mode: bootstrap only -- existing repos will be skipped") |
1038 | 1183 |
|
1039 | | - # Create all server repos |
1040 | | - success_count = 0 |
| 1184 | + written = [] |
| 1185 | + skipped = [] |
1041 | 1186 | failed = [] |
| 1187 | + |
| 1188 | + if create_mcp_base_repo(force=args.force, dry_run=args.dry_run): |
| 1189 | + written.append("mcp-base") |
| 1190 | + else: |
| 1191 | + skipped.append("mcp-base") |
| 1192 | + |
1042 | 1193 | for server in SERVERS: |
1043 | 1194 | try: |
1044 | | - create_repo(server) |
1045 | | - success_count += 1 |
| 1195 | + if create_repo(server, force=args.force, dry_run=args.dry_run): |
| 1196 | + written.append(server["repo"]) |
| 1197 | + else: |
| 1198 | + skipped.append(server["repo"]) |
1046 | 1199 | except Exception as exc: |
1047 | 1200 | print(f" FAILED {server['repo']}: {exc}") |
1048 | 1201 | failed.append(server["repo"]) |
1049 | 1202 |
|
1050 | 1203 | print(f"\n{'='*60}") |
1051 | | - print(f"COMPLETE: {success_count + 1}/{len(SERVERS) + 1} repos created") |
| 1204 | + print(f"written: {len(written)} skipped: {len(skipped)} failed: {len(failed)}") |
| 1205 | + if skipped: |
| 1206 | + print(f"SKIPPED: {skipped}") |
1052 | 1207 | if failed: |
1053 | 1208 | print(f"FAILED: {failed}") |
1054 | | - else: |
1055 | | - print("All repos created successfully!") |
1056 | 1209 |
|
1057 | | - # Print settings.json update instructions |
1058 | | - print("\n--- settings.json paths to update ---") |
1059 | | - for server in SERVERS: |
1060 | | - new_path = str(WORKSPACE / server["repo"] / "server.py").replace("\\", "/") |
1061 | | - print(f' "{server["repo"].replace("mcp-", "")}": "{new_path}"') |
| 1210 | + if written: |
| 1211 | + print("\n--- settings.json paths to update ---") |
| 1212 | + for server in SERVERS: |
| 1213 | + if server["repo"] not in written: |
| 1214 | + continue |
| 1215 | + new_path = str(WORKSPACE / server["repo"] / "server.py").replace("\\", "/") |
| 1216 | + print(f' "{server["repo"].replace("mcp-", "")}": "{new_path}"') |
| 1217 | + |
| 1218 | + if args.dry_run: |
| 1219 | + return 0 |
| 1220 | + return 1 if (skipped or failed) else 0 |
1062 | 1221 |
|
1063 | 1222 |
|
1064 | 1223 | if __name__ == "__main__": |
1065 | | - main() |
| 1224 | + sys.exit(main()) |
0 commit comments