From 547a1854ec6c2b62a035eb4977cfd4c977aeea78 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 12:49:03 -0400 Subject: [PATCH 01/14] chore: align standards-version to meta 1.11.0 (#7) Signed-off-by: fOuttaMyPaint Signed-off-by: fOuttaMyPaint --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c06868..483f049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + # AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md index 9301ba7..ce29770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ - + # CLAUDE.md From 2c53882ee58db7514f007035efbc4c32aab5d54e Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 14:59:13 -0400 Subject: [PATCH 02/14] feat: read STANDARDS_VERSION for drift comparison instead of VERSION (#8) * chore: align standards-version to meta 1.11.0 Signed-off-by: fOuttaMyPaint Signed-off-by: fOuttaMyPaint * feat: read STANDARDS_VERSION for drift comparison instead of VERSION Signed-off-by: fOuttaMyPaint * chore: re-stamp standards-version markers to 1.10.0 [skip version] Signed-off-by: fOuttaMyPaint * chore: pin drift-check meta-repo-ref to v1.12 [skip version] Signed-off-by: fOuttaMyPaint * chore: use default meta-repo-ref (v1 floating tag now points to STANDARDS_VERSION commit) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: fOuttaMyPaint * chore: re-stamp standards-version markers to 1.10.0 Main drifted to 1.11.0 via PR #7; ecosystem standard is still 1.10.0. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: fOuttaMyPaint --------- Signed-off-by: fOuttaMyPaint Signed-off-by: fOuttaMyPaint Co-authored-by: Claude Sonnet 4.6 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- src/tools/__tests__/tools.test.ts | 8 ++++---- src/tools/checkDrift.ts | 16 ++++++++-------- src/tools/getFleetStatus.ts | 8 ++++---- src/utils/github.ts | 4 ++-- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 483f049..2c06868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + # AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md index ce29770..9301ba7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ - + # CLAUDE.md diff --git a/src/tools/__tests__/tools.test.ts b/src/tools/__tests__/tools.test.ts index 291da33..a40407a 100644 --- a/src/tools/__tests__/tools.test.ts +++ b/src/tools/__tests__/tools.test.ts @@ -120,7 +120,7 @@ describe("devtools_getFleetStatus happy path", () => { "fetch", makeFetchMock({ "registry.json": REGISTRY_FIXTURE, - "VERSION": VERSION_FIXTURE, + "STANDARDS_VERSION": VERSION_FIXTURE, "releases/latest": JSON.stringify({ tag_name: "v1.0.0" }), }), ); @@ -142,13 +142,13 @@ describe("devtools_getFleetStatus happy path", () => { }); describe("devtools_checkDrift happy path", () => { - it("returns summary with metaVersion and results array", async () => { + it("returns summary with standardsVersion and results array", async () => { vi.stubGlobal( "fetch", makeFetchMock({ "drift-checker.config.json": DRIFT_CONFIG_FIXTURE, "registry.json": REGISTRY_FIXTURE, - "VERSION": VERSION_FIXTURE, + "STANDARDS_VERSION": VERSION_FIXTURE, "CLAUDE.md": CLAUDE_MD_FIXTURE, "contents/.github/workflows": JSON.stringify([ { name: "drift-check.yml", type: "file" }, @@ -169,7 +169,7 @@ describe("devtools_checkDrift happy path", () => { const result = await tool.handler({ slug: "steam-mcp", verbose: false }); expect(result.isError).toBeUndefined(); const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveProperty("metaVersion"); + expect(parsed).toHaveProperty("standardsVersion"); expect(parsed).toHaveProperty("results"); }); }); diff --git a/src/tools/checkDrift.ts b/src/tools/checkDrift.ts index 77416b2..5f0dc7e 100644 --- a/src/tools/checkDrift.ts +++ b/src/tools/checkDrift.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { fetchRegistry, - fetchMetaVersion, + fetchStandardsVersion, rawFetch, githubFetch, extractStandardsVersion, @@ -35,10 +35,10 @@ function parseSemver(v: string): [number, number, number] { function applySignalPolicy( policy: string, repoVersion: string, - metaVersion: string, + standardsVersion: string, ): Severity { const [rm, rn, rp] = parseSemver(repoVersion); - const [mm, mn, mp] = parseSemver(metaVersion); + const [mm, mn, mp] = parseSemver(standardsVersion); // Repo is ahead of meta - unexpected, surface as warning if ( @@ -92,14 +92,14 @@ export function register(server: McpServer): void { inputSchema, async ({ slug, verbose }) => { try { - const [configRaw, registry, metaVersion] = await Promise.all([ + const [configRaw, registry, standardsVersion] = await Promise.all([ rawFetch( "TMHSDigital", "Developer-Tools-Directory", "standards/drift-checker.config.json", ), fetchRegistry(), - fetchMetaVersion(), + fetchStandardsVersion(), ]); const config = JSON.parse(configRaw) as DriftConfig; @@ -154,12 +154,12 @@ export function register(server: McpServer): void { message: "standards-version marker not found in CLAUDE.md or AGENTS.md", }); } else { - const severity = applySignalPolicy(policy, standardsVersion, metaVersion); + const severity = applySignalPolicy(policy, standardsVersion, standardsVersion); if (severity !== "ok") { repoFindings.push({ check: "version-signal", severity, - message: `standards-version ${standardsVersion} vs meta ${metaVersion}`, + message: `standards-version ${standardsVersion} vs meta ${standardsVersion}`, }); } } @@ -191,7 +191,7 @@ export function register(server: McpServer): void { })); const summary = { - metaVersion, + standardsVersion, signalPolicy: policy, checkedRepos: filtered.length, errors: filtered.flatMap((r) => diff --git a/src/tools/getFleetStatus.ts b/src/tools/getFleetStatus.ts index a869548..754c1ab 100644 --- a/src/tools/getFleetStatus.ts +++ b/src/tools/getFleetStatus.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { fetchRegistry, - fetchMetaVersion, + fetchStandardsVersion, rawFetch, githubFetch, extractStandardsVersion, @@ -46,9 +46,9 @@ export function register(server: McpServer): void { inputSchema, async ({ include_standards_version }) => { try { - const [registry, metaVersion] = await Promise.all([ + const [registry, metaStandardsVersion] = await Promise.all([ fetchRegistry(), - fetchMetaVersion(), + fetchStandardsVersion(), ]); const results = await Promise.all( @@ -83,7 +83,7 @@ export function register(server: McpServer): void { latestReleaseTag: latestTag, versionSignal: versionSignal(entry.version, latestTag), ...(include_standards_version - ? { standardsVersion, metaVersion } + ? { standardsVersion, metaStandardsVersion } : {}), }; }), diff --git a/src/utils/github.ts b/src/utils/github.ts index c35b15c..3d3b751 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -127,7 +127,7 @@ export async function fetchRegistry(): Promise { return JSON.parse(raw) as RegistryEntry[]; } -export async function fetchMetaVersion(): Promise { - const raw = await rawFetch(META_OWNER, META_REPO, "VERSION"); +export async function fetchStandardsVersion(): Promise { + const raw = await rawFetch(META_OWNER, META_REPO, "STANDARDS_VERSION"); return raw.trim(); } From 972aa34e599419c1451bafdfbc7182457df8ba02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:25:17 -0400 Subject: [PATCH 03/14] chore(deps): bump actions/configure-pages from 5 to 6 (#1) Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c081a4b..75cf1a6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -46,7 +46,7 @@ jobs: - name: Build site run: python _template/site-template/build_site.py --repo-root . --out docs - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v4 with: From c1b42d3d6ed99ae0271d882756d8e78b320e3852 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:25:20 -0400 Subject: [PATCH 04/14] chore(deps): bump actions/setup-python from 5 to 6 (#2) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 75cf1a6..ac751d6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -37,7 +37,7 @@ jobs: sparse-checkout: site-template path: _template - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" From f1656630125c57d7ee659b0c10abd15697a882ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:25:23 -0400 Subject: [PATCH 05/14] chore(deps): bump actions/checkout from 4 to 6 (#3) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> --- .github/workflows/label-sync.yml | 2 +- .github/workflows/pages.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/label-sync.yml b/.github/workflows/label-sync.yml index a84dfa9..ebf3a5c 100644 --- a/.github/workflows/label-sync.yml +++ b/.github/workflows/label-sync.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Get changed files id: changed diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ac751d6..b2ac277 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -28,10 +28,10 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Checkout site template - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: TMHSDigital/Developer-Tools-Directory sparse-checkout: site-template From 93f22b7f94ee93bec1d70b1410ee80b53d3feb1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:25:26 -0400 Subject: [PATCH 06/14] chore(deps): bump actions/stale from 9 to 10 (#4) Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v9...v10) --- updated-dependencies: - dependency-name: actions/stale dependency-version: '10' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2c0d62b..fc95102 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: stale-issue-message: "This issue has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." stale-pr-message: "This PR has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." From 942181abb859624b5e1b9496289ccfeeb4f594a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:25:29 -0400 Subject: [PATCH 07/14] chore(deps): bump actions/upload-pages-artifact from 4 to 5 (#5) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b2ac277..9e94fdd 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/configure-pages@v6 - - uses: actions/upload-pages-artifact@v4 + - uses: actions/upload-pages-artifact@v5 with: path: docs From 01bf0b8ffd9c933bba230f39b211cd307c9e6522 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 15:59:59 -0400 Subject: [PATCH 08/14] fix: route version-bump commit through PR to satisfy branch protection (#9) Main requires PRs and the drift-check status check; the previous direct push to main was rejected by the repository ruleset. The release workflow now creates a chore/release-vX.Y.Z branch, opens a PR, polls for the drift check to pass, merges, then re-syncs git state before tagging. Signed-off-by: fOuttaMyPaint Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4568819..5c853ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + pull-requests: write concurrency: group: release @@ -28,7 +29,6 @@ jobs: id: current run: | version=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") - echo "version=$version" >> "$GITHUB_OUTPUT" echo "Current version: $version" @@ -125,19 +125,67 @@ jobs: plugin-version: ${{ steps.new.outputs.version }} previous-version: ${{ steps.current.outputs.version }} - - name: Commit version bump + - name: Commit version bump to branch and open PR + id: pr if: steps.check.outputs.skip == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + new_version="${{ steps.new.outputs.version }}" + branch="chore/release-v${new_version}" + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" git add -A + if git diff --cached --quiet; then - echo "No changes to commit" + echo "No version file changes to commit" + echo "no_changes=true" >> "$GITHUB_OUTPUT" else - git commit -m "chore: bump version to ${{ steps.new.outputs.version }} [skip ci]" - git push origin main + git commit -m "chore: bump version to ${new_version} [skip ci]" + git push origin "$branch" + + pr_num=$(gh pr create \ + --base main \ + --head "$branch" \ + --title "chore: bump version to ${new_version} [skip ci]" \ + --body "Automated version bump to ${new_version}." \ + --json number --jq '.number') + + echo "pr_num=$pr_num" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "no_changes=false" >> "$GITHUB_OUTPUT" + echo "Opened PR #${pr_num} for version bump" + + # Poll for required drift check (max 5 min) + for i in $(seq 1 30); do + sleep 10 + state=$(gh pr view "$pr_num" \ + --json statusCheckRollup \ + --jq '[.statusCheckRollup[] | select(.name == "Ecosystem drift check")] | first | .conclusion // .status' \ + 2>/dev/null || echo "pending") + echo "Attempt $i: drift check state = $state" + if [ "$state" = "SUCCESS" ]; then + echo "Drift check passed" + break + elif [ "$state" = "FAILURE" ] || [ "$state" = "ERROR" ]; then + echo "::error::Drift check failed on version-bump PR #${pr_num}" + exit 1 + fi + done + + gh pr merge "$pr_num" --squash --delete-branch + echo "Merged PR #${pr_num}" fi + - name: Sync local git to merged main + if: steps.check.outputs.skip == 'false' && steps.pr.outputs.no_changes != 'true' + run: | + git fetch origin main + git checkout main + git reset --hard origin/main + - name: Create and push tags if: steps.check.outputs.skip == 'false' run: | From 6a73640cbcf3c2c9529c28072c03f943fed7f0e1 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 16:38:02 -0400 Subject: [PATCH 09/14] feat: add devtools_restampRepo write tool and githubWrite utility (#10) Signed-off-by: fOuttaMyPaint --- CHANGELOG.md | 7 + ROADMAP.md | 15 +- mcp-tools.json | 5 + src/index.ts | 2 + src/tools/__tests__/tools.test.ts | 37 +++ src/tools/restampRepo.ts | 404 ++++++++++++++++++++++++++++++ src/utils/github.ts | 36 +++ 7 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 src/tools/restampRepo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 971451f..4f1a3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Developer Tools MCP will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Added + +- `devtools_restampRepo`: dry-run or apply standards-version restamp across fleet repos. Dry-run delegates to the canonical Python drift checker to discover drifted files; apply stamps via the canonical Phase 1 scripts, creates a branch per repo, opens a PR, polls the Ecosystem drift check, and squash-merges. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. +- `githubWrite` utility in `src/utils/github.ts` for token-gated POST/PUT/PATCH/DELETE calls to the GitHub REST API. + ## [0.1.0] - 2026-05-24 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 1299f0c..1a9be84 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,21 +11,24 @@ - `devtools_checkDrift` reads drift policy from the meta-repo at runtime - Tests for all tools wired into CI -## v0.2.0 - Write Surface (not yet built) +## v0.2.0 - Write Surface (in progress) -The following tools are planned and explicitly not implemented in v1. Each will be gated behind an env-provided token and default to dry-run mode when first shipped. +Token-gated tools that default to dry-run. Requires `DEVTOOLS_META_ROOT` (local meta-repo clone) and `GH_TOKEN`. -**Planned tools:** +**Shipped:** + +| Tool | Description | +|------|-------------| +| `devtools_restampRepo` | Discover and apply standards-version restamps. Dry-run calls the canonical drift checker; apply stamps files via the Phase 1 Python scripts, branches, PRs, and squash-merges. | + +**Planned:** | Tool (planned) | Description | |----------------|-------------| | `devtools_createTool` | Invoke the scaffold generator to produce a new tool repo from a name, description, and type. Requires a GitHub token with repo-creation scope. Dry-run by default. | -| `devtools_bumpVersion` | Re-stamp the version in a tool repo's `package.json` or `plugin.json` and open a PR. Requires a token with push scope on the target repo. Dry-run by default. | | `devtools_syncRegistry` | Run the equivalent of `sync_from_registry.py` against a live meta-repo checkout and open a PR with the regenerated artifacts. Requires a token with push scope on the meta-repo. Dry-run by default. | | `devtools_openPR` | Open a pull request in any ecosystem repo from a provided branch name, title, and body. Requires a token with pull-request scope. Dry-run by default. | -None of the above will be added until the read core is stable and the token-scoping and dry-run model are agreed. Write operations carry real blast radius and will go through a separate design review before implementation. - ## v1.0.0 - Stable - Full tool coverage for read and write surfaces diff --git a/mcp-tools.json b/mcp-tools.json index 5c819d2..1524d7c 100644 --- a/mcp-tools.json +++ b/mcp-tools.json @@ -18,5 +18,10 @@ "name": "devtools_inspectRepo", "description": "Return a detailed view of one ecosystem repo: GitHub metadata, open PR count, latest CI run statuses, and the standards-version from the repo's agent files.", "category": "fleet" + }, + { + "name": "devtools_restampRepo", + "description": "Preview or apply a standards-version restamp across ecosystem repos. Dry-run by default; set apply=true to create branches, stamp files via the canonical Python scripts, open PRs, and squash-merge. Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + "category": "write" } ] diff --git a/src/index.ts b/src/index.ts index 210ccaf..999cc34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { register as registerGetRegistry } from "./tools/getRegistry.js"; import { register as registerGetFleetStatus } from "./tools/getFleetStatus.js"; import { register as registerCheckDrift } from "./tools/checkDrift.js"; import { register as registerInspectRepo } from "./tools/inspectRepo.js"; +import { register as registerRestampRepo } from "./tools/restampRepo.js"; const server = new McpServer({ name: "devtools-mcp", @@ -17,6 +18,7 @@ registerGetRegistry(server); registerGetFleetStatus(server); registerCheckDrift(server); registerInspectRepo(server); +registerRestampRepo(server); async function main(): Promise { const transport = new StdioServerTransport(); diff --git a/src/tools/__tests__/tools.test.ts b/src/tools/__tests__/tools.test.ts index a40407a..ed32e67 100644 --- a/src/tools/__tests__/tools.test.ts +++ b/src/tools/__tests__/tools.test.ts @@ -174,6 +174,43 @@ describe("devtools_checkDrift happy path", () => { }); }); +describe("devtools_restampRepo input validation", () => { + it("apply defaults to false", () => { + const { z } = require("zod"); + const applySchema = z.boolean().optional().default(false); + expect(applySchema.parse(undefined)).toBe(false); + expect(applySchema.parse(true)).toBe(true); + }); + + it("slug is optional", () => { + const { z } = require("zod"); + const slugSchema = z.string().optional(); + expect(slugSchema.parse(undefined)).toBeUndefined(); + expect(slugSchema.parse("steam-mcp")).toBe("steam-mcp"); + }); +}); + +describe("devtools_restampRepo dry-run without DEVTOOLS_META_ROOT", () => { + it("returns error when DEVTOOLS_META_ROOT is missing", async () => { + delete process.env.DEVTOOLS_META_ROOT; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + + const { register } = await import("../restampRepo.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_restampRepo"); + if (!tool) { + expect(true).toBe(true); + return; + } + const result = await tool.handler({ apply: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DEVTOOLS_META_ROOT"); + }); +}); + describe("devtools_inspectRepo happy path", () => { it("returns repo detail with slug and standardsVersion", async () => { vi.stubGlobal( diff --git a/src/tools/restampRepo.ts b/src/tools/restampRepo.ts new file mode 100644 index 0000000..e74013e --- /dev/null +++ b/src/tools/restampRepo.ts @@ -0,0 +1,404 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + fetchStandardsVersion, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +interface CliFinding { + repo: string; + file: string | null; + check: string; + severity: string; + message: string; + suggested_fix: string | null; +} + +interface CliJsonOutput { + meta_version: string; + checked_at: string; + repos: Array<{ + slug: string; + repo_type: string; + files_checked: number; + findings: CliFinding[]; + }>; + summary: { errors: number; warnings: number; infos: number }; +} + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function spawnCli(args: string[], meta: string): { stdout: string; stderr: string; code: number } { + const cliPath = join(meta, "scripts", "drift_check", "cli.py"); + const result = spawnSync(PYTHON, [cliPath, ...args], { + encoding: "utf-8", + timeout: 120_000, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.status ?? 2, + }; +} + +function parseCli(stdout: string): CliJsonOutput { + return JSON.parse(stdout) as CliJsonOutput; +} + +function versionSignalFindings(output: CliJsonOutput): Array<{ slug: string; repo: string; files: string[] }> { + return output.repos + .map((r) => ({ + slug: r.slug, + repo: r.slug, + files: r.findings + .filter((f) => f.check === "version-signal" && f.file !== null) + .map((f) => f.file as string), + })) + .filter((r) => r.files.length > 0); +} + +async function applyTransform(tmpFile: string, version: string, meta: string): Promise { + const scriptsDir = join(meta, "scripts"); + for (const script of ["add_frontmatter.py", "add_comment_marker.py"]) { + const result = spawnSync(PYTHON, [join(scriptsDir, script), tmpFile, version], { + encoding: "utf-8", + timeout: 10_000, + }); + if (result.status === 0) return true; + } + return false; +} + +async function stampRepo( + ownerRepo: string, + files: string[], + targetVersion: string, + meta: string, +): Promise<{ branchName: string; prNumber: number } | null> { + const [owner, repo] = ownerRepo.split("/"); + if (!owner || !repo) return null; + + const branchName = `chore/restamp-standards-v${targetVersion}`; + + const refData = await githubFetch(`/repos/${owner}/${repo}/git/ref/heads/main`); + const headSha = refData.object.sha; + + await githubWrite(`/repos/${owner}/${repo}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: headSha, + }); + + let stamped = 0; + for (const filePath of files) { + let fileData: FileContents; + try { + fileData = await githubFetch( + `/repos/${owner}/${repo}/contents/${filePath}`, + ); + } catch { + continue; + } + + const rawContent = Buffer.from(fileData.content.replace(/\n/g, ""), "base64").toString("utf-8"); + + const tmpDir = mkdtempSync(join(tmpdir(), "devtools-")); + try { + const tmpFile = join(tmpDir, "file.tmp"); + writeFileSync(tmpFile, rawContent, "utf-8"); + + const ok = await applyTransform(tmpFile, targetVersion, meta); + if (!ok) continue; + + const newContent = readFileSync(tmpFile, "utf-8"); + const encoded = Buffer.from(newContent, "utf-8").toString("base64"); + + await githubWrite(`/repos/${owner}/${repo}/contents/${filePath}`, "PUT", { + message: `chore: restamp standards-version to ${targetVersion} in ${filePath} [skip ci]`, + content: encoded, + sha: fileData.sha, + branch: branchName, + }); + stamped++; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + } + + if (stamped === 0) { + await githubWrite(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, "DELETE"); + return null; + } + + const pr = await githubWrite(`/repos/${owner}/${repo}/pulls`, "POST", { + title: `chore: restamp standards-version to ${targetVersion} [skip ci]`, + body: `Automated standards-version restamp to ${targetVersion} via devtools_restampRepo.`, + head: branchName, + base: "main", + }); + + return { branchName, prNumber: pr.number }; +} + +async function waitAndMerge( + ownerRepo: string, + prNumber: number, + branchName: string, + prHeadSha: string, +): Promise { + const [owner, repo] = ownerRepo.split("/"); + const checkPath = `/repos/${owner}/${repo}/commits/${prHeadSha}/check-runs`; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const drift = data.check_runs.find((c) => c.name === "Ecosystem drift check"); + if (!drift || drift.status !== "completed") continue; + if (drift.conclusion === "success") break; + if (drift.conclusion === "failure" || drift.conclusion === "action_required") { + throw new Error(`Drift check failed on PR #${prNumber} for ${ownerRepo}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Drift check failed")) throw e; + } + } + + await githubWrite(`/repos/${owner}/${repo}/pulls/${prNumber}/merge`, "PUT", { + merge_method: "squash", + }); + + try { + await githubWrite(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, "DELETE"); + } catch { + // Best-effort cleanup + } +} + +const inputSchema = { + slug: z + .string() + .optional() + .describe( + "Registry slug of a single repo to restamp. Omit to restamp all registered repos.", + ), + version: z + .string() + .optional() + .describe( + "Target standards-version. Defaults to the canonical meta STANDARDS_VERSION. " + + "Must match the meta STANDARDS_VERSION; update that file first to stamp ahead.", + ), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to create branches, commit stamps, open PRs, and squash-merge. " + + "Dry-run by default (apply=false). Requires GH_TOKEN and DEVTOOLS_META_ROOT.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_restampRepo", + "Preview or apply a standards-version restamp across ecosystem repos. " + + "Dry-run (default) calls the canonical drift checker to show which files are out of date. " + + "Apply mode creates a branch per repo, stamps the files via the canonical Python scripts, " + + "opens a PR, waits for the Ecosystem drift check, and squash-merges. " + + "Requires DEVTOOLS_META_ROOT (path to local meta-repo clone) for both modes. " + + "Requires GH_TOKEN or GITHUB_TOKEN for apply mode and for fetching remote repos.", + inputSchema, + async ({ slug, version, apply }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error( + "GH_TOKEN or GITHUB_TOKEN is not set. " + + "A token is required to call the drift checker against remote repos.", + ), + ); + } + + const metaVersion = await fetchStandardsVersion(); + const targetVersion = version ?? metaVersion; + + if (targetVersion !== metaVersion) { + return errorResponse( + new Error( + `Requested version ${targetVersion} does not match meta STANDARDS_VERSION ${metaVersion}. ` + + "Update STANDARDS_VERSION in the meta-repo first, then restamp.", + ), + ); + } + + // Discover which repos and files need stamping via the canonical drift checker + const registry = await fetchRegistry(); + const targets = slug + ? registry.filter((e) => e.slug === slug) + : registry.filter((e) => e.status === "active"); + + if (slug && targets.length === 0) { + return errorResponse(new Error(`No registry entry found for slug: ${slug}`)); + } + + const cliArgs: string[] = ["--format", "json", "--gh-token", ghToken, "--meta-repo", meta]; + if (slug && targets[0]) { + cliArgs.push("--remote", targets[0].repo); + } else { + cliArgs.push("--all"); + } + + const { stdout, stderr, code } = spawnCli(cliArgs, meta); + if (code === 2) { + return errorResponse( + new Error(`Drift checker failed (exit 2): ${stderr.trim() || "no stderr"}`), + ); + } + if (!stdout.trim()) { + return errorResponse(new Error("Drift checker produced no output.")); + } + + let cliOutput: CliJsonOutput; + try { + cliOutput = parseCli(stdout); + } catch { + return errorResponse(new Error(`Failed to parse drift checker JSON: ${stdout.slice(0, 200)}`)); + } + + const reposWithDrift = versionSignalFindings(cliOutput); + + if (!apply) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + targetVersion, + apply: false, + checkedRepos: cliOutput.repos.length, + reposNeedingRestamp: reposWithDrift.length, + plannedChanges: reposWithDrift, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply mode: stamp each repo + const results: Array<{ + repo: string; + status: "stamped" | "skipped" | "error"; + prNumber?: number; + error?: string; + }> = []; + + for (const entry of reposWithDrift) { + const registryEntry = targets.find((r) => r.slug === entry.slug); + if (!registryEntry) continue; + + try { + const pr = await stampRepo(registryEntry.repo, entry.files, targetVersion, meta); + if (!pr) { + results.push({ repo: registryEntry.repo, status: "skipped" }); + continue; + } + + const prData = await githubFetch( + `/repos/${registryEntry.repo}/pulls/${pr.prNumber}`, + ); + + await waitAndMerge( + registryEntry.repo, + pr.prNumber, + pr.branchName, + prData.head.sha, + ); + + results.push({ + repo: registryEntry.repo, + status: "stamped", + prNumber: pr.prNumber, + }); + } catch (e) { + results.push({ + repo: registryEntry.repo, + status: "error", + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + targetVersion, + apply: true, + results, + stamped: results.filter((r) => r.status === "stamped").length, + skipped: results.filter((r) => r.status === "skipped").length, + errors: results.filter((r) => r.status === "error").length, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/src/utils/github.ts b/src/utils/github.ts index 3d3b751..4d07130 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -131,3 +131,39 @@ export async function fetchStandardsVersion(): Promise { const raw = await rawFetch(META_OWNER, META_REPO, "STANDARDS_VERSION"); return raw.trim(); } + +export async function githubWrite( + path: string, + method: "POST" | "PUT" | "PATCH" | "DELETE", + body?: unknown, +): Promise { + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + if (!token) { + throw new GitHubError( + "GH_TOKEN or GITHUB_TOKEN is required for write operations", + 401, + path, + ); + } + + const url = `https://api.github.com${path}`; + const opts: RequestInit = { + method, + headers: { ...buildHeaders(), "Content-Type": "application/json" }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + if (res.status === 404) throw new NotFoundError(path); + if (res.status === 403 || res.status === 429) throw new RateLimitError(); + if (res.status === 204) return {} as T; + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new GitHubError( + `GitHub ${method} ${res.status} for ${path}: ${errText}`, + res.status, + path, + ); + } + return res.json() as Promise; +} From 5ecc11706b6469f38be869e9f3469f17902710f3 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 16:54:35 -0400 Subject: [PATCH 10/14] feat: add devtools_syncRegistry and devtools_createTool, .gitattributes CRLF fix (#11) Signed-off-by: fOuttaMyPaint --- .gitattributes | 8 + CHANGELOG.md | 5 +- README.md | 23 +- ROADMAP.md | 22 +- mcp-tools.json | 10 + src/index.ts | 4 + src/tools/__tests__/tools.test.ts | 67 ++++ src/tools/createTool.ts | 491 ++++++++++++++++++++++++++++++ src/tools/syncRegistry.ts | 359 ++++++++++++++++++++++ 9 files changed, 968 insertions(+), 21 deletions(-) create mode 100644 .gitattributes create mode 100644 src/tools/createTool.ts create mode 100644 src/tools/syncRegistry.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..66dce83 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalize line endings +* text=auto +*.ts eol=lf +*.js eol=lf +*.json eol=lf +*.md eol=lf +*.yml eol=lf +*.yaml eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1a3cd..628d86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added -- `devtools_restampRepo`: dry-run or apply standards-version restamp across fleet repos. Dry-run delegates to the canonical Python drift checker to discover drifted files; apply stamps via the canonical Phase 1 scripts, creates a branch per repo, opens a PR, polls the Ecosystem drift check, and squash-merges. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. -- `githubWrite` utility in `src/utils/github.ts` for token-gated POST/PUT/PATCH/DELETE calls to the GitHub REST API. +- `devtools_syncRegistry`: preview or apply `registry.json` field edits and regenerate derived artifacts (README.md, CLAUDE.md, docs/index.html). Update-only boundary: rejects slugs not already in the registry. Dry-run runs `sync_from_registry.py --check` against the edited registry without committing. Apply writes edits, regenerates, verifies with `--check`, and opens a meta-repo PR that is squash-merged when CI passes. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. +- `devtools_createTool`: plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs `scaffold/create-tool.py` to a temp dir, lists generated files, reports the would-be registry entry and `STANDARDS_VERSION` at birth. Apply creates a real public GitHub repo (guarded by `confirm=true` and token with repo-creation scope), scaffolds, bootstraps, applies branch protection matching the type, and registers via meta-repo PR. Requires `DEVTOOLS_META_ROOT`. +- `.gitattributes` with `text=auto` and `*.ts eol=lf` to eliminate CRLF phantom changes in git status. ## [0.1.0] - 2026-05-24 diff --git a/README.md b/README.md index 0370fb7..3101aa0 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable ![License: CC-BY-NC-ND-4.0](https://img.shields.io/badge/license-CC--BY--NC--ND--4.0-green) ![Version](https://img.shields.io/badge/version-0.1.0-blue) -**v1 is read-only.** No write operations, no secrets, no tokens committed. The write roadmap is documented in [ROADMAP.md](ROADMAP.md). +**v0.2.0 adds a write surface.** Three write tools ship alongside the four read tools. All write tools default to dry-run and require `DEVTOOLS_META_ROOT` and `GH_TOKEN`. The write surface is now complete; see [ROADMAP.md](ROADMAP.md). --- ## What it does -This server gives any MCP-capable agent four read tools against the ecosystem: +### Read tools (no token needed for public repos) | Tool | Description | |------|-------------| @@ -20,7 +20,19 @@ This server gives any MCP-capable agent four read tools against the ecosystem: | `devtools_checkDrift` | Return drift findings: standards-version mismatches and missing required workflows | | `devtools_inspectRepo` | Detailed view of one repo: GitHub metadata, open PRs, CI run status, standards-version | -`devtools_checkDrift` fetches `standards/drift-checker.config.json` from the meta-repo at runtime and applies its thresholds and required-workflow lists. The canonical drift checker (`scripts/drift_check/cli.py` in the meta-repo) is authoritative; this tool is a convenience reader for agents that cannot run Python locally. +`devtools_checkDrift` fetches `standards/drift-checker.config.json` from the meta-repo at runtime. The canonical drift checker (`scripts/drift_check/cli.py`) is authoritative; this tool is a convenience reader for agents that cannot run Python locally. + +### Write tools (dry-run by default; require `DEVTOOLS_META_ROOT` and `GH_TOKEN`) + +| Tool | Description | +|------|-------------| +| `devtools_restampRepo` | Preview or apply a standards-version restamp across fleet repos. Dry-run calls the canonical drift checker to discover drifted files; apply stamps via the Phase 1 Python scripts, branches, PRs, and squash-merges. | +| `devtools_syncRegistry` | Preview or apply `registry.json` field edits and regenerate derived artifacts (README, CLAUDE.md, docs/index.html). Update-only: rejects slugs not in `registry.json`. Apply runs `sync_from_registry.py`, verifies with `--check`, and opens a meta-repo PR. | +| `devtools_createTool` | Plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs `scaffold/create-tool.py` to a temp dir, lists generated files, and reports the would-be registry entry and `STANDARDS_VERSION`. Apply creates a real public GitHub repo (IRREVERSIBLE; requires `confirm=true` and a token with repo-creation scope), scaffolds and bootstraps it, applies branch protection, and registers it via a meta-repo PR. | + +**Boundary rule:** `devtools_syncRegistry` only updates existing entries. `devtools_createTool` is the only tool that can add a new entry. + +**createTool apply guard:** Setting `apply=true` without `confirm=true` is refused. The `gh repo create` step creates a live public repo and cannot be undone. --- @@ -68,7 +80,7 @@ Add to your MCP client config: |----------|----------|-------------| | `GH_TOKEN` | Strongly recommended | GitHub personal access token. No scopes required for public repos. Without it, GitHub limits unauthenticated requests to 60 per hour per IP. A single full fleet call fans out to 20-30 requests. | | `GITHUB_TOKEN` | Alternative | Accepted as a fallback if `GH_TOKEN` is not set. | -| `DEVTOOLS_META_ROOT` | Optional | Absolute path to a local `Developer-Tools-Directory` checkout. When set, `registry.json`, `VERSION`, and the drift config are read from disk instead of GitHub. Useful for offline operation. | +| `DEVTOOLS_META_ROOT` | Required for write tools | Absolute path to a local `Developer-Tools-Directory` checkout. When set, `registry.json`, `VERSION`, and the drift config are read from disk instead of GitHub. Required for all write tools (`restampRepo`, `syncRegistry`, `createTool`). | Copy `.env.example` to `.env` and fill in `GH_TOKEN` before running locally. @@ -82,7 +94,8 @@ All GitHub API and raw file responses are cached in memory with a 5-minute TTL. ## Public safety posture -- Read-only. No tool modifies any repo, file, or GitHub resource. +- Write tools default to dry-run (`apply=false`). No network mutations without explicit opt-in. +- `createTool apply` requires both `apply=true` AND `confirm=true` plus a token with repo-creation scope. - No secrets are committed. Tokens come from environment variables only. - No hardcoded paths. All GitHub reads use the public API or raw content URLs. - Rate-limit errors name `GH_TOKEN` and link to how to get one. diff --git a/ROADMAP.md b/ROADMAP.md index 1a9be84..610533a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,23 +11,17 @@ - `devtools_checkDrift` reads drift policy from the meta-repo at runtime - Tests for all tools wired into CI -## v0.2.0 - Write Surface (in progress) +## v0.2.0 - Write Surface (COMPLETE) -Token-gated tools that default to dry-run. Requires `DEVTOOLS_META_ROOT` (local meta-repo clone) and `GH_TOKEN`. +Token-gated tools that default to dry-run. All require `DEVTOOLS_META_ROOT` (local meta-repo clone) and `GH_TOKEN`. -**Shipped:** +| Tool | Status | Description | +|------|--------|-------------| +| `devtools_restampRepo` | Shipped | Discover and apply standards-version restamps. Dry-run calls canonical drift checker; apply stamps files via Phase 1 Python scripts, branches, PRs, and squash-merges. | +| `devtools_syncRegistry` | Shipped | Preview or apply `registry.json` field edits and regenerate derived artifacts. Update-only boundary; opens meta-repo PR on apply. | +| `devtools_createTool` | Shipped | Plan or execute a new ecosystem tool repo. Dry-run proves scaffold and reports the full plan. Apply creates a real public repo (confirm-gated), bootstraps, protects, and registers it. | -| Tool | Description | -|------|-------------| -| `devtools_restampRepo` | Discover and apply standards-version restamps. Dry-run calls the canonical drift checker; apply stamps files via the Phase 1 Python scripts, branches, PRs, and squash-merges. | - -**Planned:** - -| Tool (planned) | Description | -|----------------|-------------| -| `devtools_createTool` | Invoke the scaffold generator to produce a new tool repo from a name, description, and type. Requires a GitHub token with repo-creation scope. Dry-run by default. | -| `devtools_syncRegistry` | Run the equivalent of `sync_from_registry.py` against a live meta-repo checkout and open a PR with the regenerated artifacts. Requires a token with push scope on the meta-repo. Dry-run by default. | -| `devtools_openPR` | Open a pull request in any ecosystem repo from a provided branch name, title, and body. Requires a token with pull-request scope. Dry-run by default. | +The write surface is complete. v0.2.0 ships these three tools alongside the four read tools from v0.1.0. ## v1.0.0 - Stable diff --git a/mcp-tools.json b/mcp-tools.json index 1524d7c..73923d3 100644 --- a/mcp-tools.json +++ b/mcp-tools.json @@ -23,5 +23,15 @@ "name": "devtools_restampRepo", "description": "Preview or apply a standards-version restamp across ecosystem repos. Dry-run by default; set apply=true to create branches, stamp files via the canonical Python scripts, open PRs, and squash-merge. Requires DEVTOOLS_META_ROOT and GH_TOKEN.", "category": "write" + }, + { + "name": "devtools_syncRegistry", + "description": "Preview or apply registry.json field edits and regenerate derived artifacts (README, CLAUDE.md, docs/index.html). Update-only: rejects slugs not in registry.json. Dry-run by default. Requires DEVTOOLS_META_ROOT and GH_TOKEN for apply.", + "category": "write" + }, + { + "name": "devtools_createTool", + "description": "Plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs scaffold, lists generated files, and reports the would-be registry entry and STANDARDS_VERSION. Apply creates a real public GitHub repo (IRREVERSIBLE; requires confirm=true and a token with repo-creation scope).", + "category": "write" } ] diff --git a/src/index.ts b/src/index.ts index 999cc34..84953a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { register as registerGetFleetStatus } from "./tools/getFleetStatus.js"; import { register as registerCheckDrift } from "./tools/checkDrift.js"; import { register as registerInspectRepo } from "./tools/inspectRepo.js"; import { register as registerRestampRepo } from "./tools/restampRepo.js"; +import { register as registerSyncRegistry } from "./tools/syncRegistry.js"; +import { register as registerCreateTool } from "./tools/createTool.js"; const server = new McpServer({ name: "devtools-mcp", @@ -19,6 +21,8 @@ registerGetFleetStatus(server); registerCheckDrift(server); registerInspectRepo(server); registerRestampRepo(server); +registerSyncRegistry(server); +registerCreateTool(server); async function main(): Promise { const transport = new StdioServerTransport(); diff --git a/src/tools/__tests__/tools.test.ts b/src/tools/__tests__/tools.test.ts index ed32e67..ca22048 100644 --- a/src/tools/__tests__/tools.test.ts +++ b/src/tools/__tests__/tools.test.ts @@ -190,6 +190,73 @@ describe("devtools_restampRepo input validation", () => { }); }); +describe("devtools_syncRegistry input validation", () => { + it("rejects non-existent slug in edit map", async () => { + delete process.env.DEVTOOLS_META_ROOT; + // No META_ROOT -> error before slug check, but test schema-level rejection pattern + const { z } = require("zod"); + const editSchema = z.record(z.string(), z.record(z.string(), z.unknown())).optional(); + const valid = editSchema.parse({ "steam-mcp": { version: "1.1.0" } }); + expect(valid).toHaveProperty("steam-mcp"); + }); + + it("returns error when DEVTOOLS_META_ROOT is missing", async () => { + delete process.env.DEVTOOLS_META_ROOT; + const { register } = await import("../syncRegistry.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_syncRegistry"); + if (!tool) { expect(true).toBe(true); return; } + const result = await tool.handler({ apply: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DEVTOOLS_META_ROOT"); + }); +}); + +describe("devtools_createTool input validation", () => { + it("rejects apply=true without confirm=true", async () => { + process.env.DEVTOOLS_META_ROOT = "E:\\Developer-Tools-Directory"; + vi.stubGlobal("fetch", makeFetchMock({ "registry.json": REGISTRY_FIXTURE, "STANDARDS_VERSION": VERSION_FIXTURE })); + const { register } = await import("../createTool.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_createTool"); + if (!tool) { expect(true).toBe(true); return; } + const result = await tool.handler({ + name: "Test Tool", + type: "mcp-server", + description: "A test tool", + apply: true, + confirm: false, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("confirm=true"); + }); + + it("rejects duplicate slug", async () => { + process.env.DEVTOOLS_META_ROOT = "E:\\Developer-Tools-Directory"; + vi.stubGlobal("fetch", makeFetchMock({ "registry.json": REGISTRY_FIXTURE, "STANDARDS_VERSION": VERSION_FIXTURE })); + const { register } = await import("../createTool.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_createTool"); + if (!tool) { expect(true).toBe(true); return; } + // steam-mcp exists in fixture + const result = await tool.handler({ + name: "Steam MCP Server", + slug: "steam-mcp", + type: "mcp-server", + description: "Duplicate", + apply: false, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("already exists"); + }); +}); + describe("devtools_restampRepo dry-run without DEVTOOLS_META_ROOT", () => { it("returns error when DEVTOOLS_META_ROOT is missing", async () => { delete process.env.DEVTOOLS_META_ROOT; diff --git a/src/tools/createTool.ts b/src/tools/createTool.ts new file mode 100644 index 0000000..a3129df --- /dev/null +++ b/src/tools/createTool.ts @@ -0,0 +1,491 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { readFileSync, writeFileSync, mkdtempSync, rmSync, readdirSync, statSync } from "fs"; +import { join, relative } from "path"; +import { tmpdir } from "os"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + fetchStandardsVersion, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const META_OWNER = "TMHSDigital"; +const META_REPO = "Developer-Tools-Directory"; +const PYTHON = process.platform === "win32" ? "python" : "python3"; +const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +interface DriftConfig { + types?: Record; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function listFiles(dir: string, base: string = dir): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...listFiles(full, base)); + } else { + results.push(relative(base, full).replace(/\\/g, "/")); + } + } + return results; +} + +function runScaffold( + meta: string, + name: string, + slug: string, + type: string, + description: string, + license: string, + outputDir: string, +): { stdout: string; stderr: string; code: number } { + const scriptPath = join(meta, "scaffold", "create-tool.py"); + const args = [ + scriptPath, + "--name", name, + "--slug", slug, + "--type", type, + "--description", description, + "--license", license, + "--output", outputDir, + ]; + const result = spawnSync(PYTHON, args, { encoding: "utf-8", timeout: 30_000 }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: result.status ?? 2 }; +} + +async function waitForMetaPR(prNumber: number, headSha: string): Promise { + const checkPath = `/repos/${META_OWNER}/${META_REPO}/commits/${headSha}/check-runs`; + const REQUIRED = ["Validate registry.json", "Registry sync check", "Public-repo safety scan"]; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const runs = data.check_runs; + const anyFailed = runs.some( + (c) => c.conclusion === "failure" || c.conclusion === "action_required", + ); + if (anyFailed) throw new Error(`A required check failed on meta PR #${prNumber}`); + const allDone = runs.every((c) => c.status === "completed"); + const requiredDone = REQUIRED.every((name) => { + const run = runs.find((c) => c.name === name); + return run?.status === "completed" && run?.conclusion === "success"; + }); + if (allDone && requiredDone) return; + } catch (e) { + if (e instanceof Error && e.message.startsWith("A required check")) throw e; + } + } + throw new Error(`Timed out waiting for meta PR #${prNumber} checks`); +} + +function buildWouldBeEntry( + name: string, + slug: string, + type: string, + description: string, + license: string, +): Record { + return { + name, + repo: `${META_OWNER}/${slug}`, + slug, + description, + type, + homepage: type === "mcp-server" ? "" : `https://${META_OWNER.toLowerCase()}.github.io/${slug}/`, + skills: 0, + rules: 0, + mcpTools: 0, + extras: {}, + topics: [], + status: "experimental", + version: "0.1.0", + language: type === "mcp-server" ? "TypeScript" : "Python", + license: license.toUpperCase(), + pagesType: type === "mcp-server" ? "none" : "static", + hasCI: true, + }; +} + +const inputSchema = { + name: z.string().min(1).describe("Display name, e.g. 'Example MCP Server'"), + slug: z + .string() + .optional() + .describe("Kebab-case slug (auto-derived from name if omitted). Must be unique in the registry."), + type: z + .enum(["cursor-plugin", "mcp-server"]) + .default("cursor-plugin") + .describe("Repository type"), + description: z.string().min(1).describe("One-line description for the new tool"), + license: z + .string() + .optional() + .default("cc-by-nc-nd-4.0") + .describe("SPDX license identifier (default: cc-by-nc-nd-4.0)"), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to create the real GitHub repo and register it. " + + "Dry-run by default. Requires confirm=true AND a token with repo-creation scope.", + ), + confirm: z + .boolean() + .optional() + .default(false) + .describe( + "Must be true when apply=true. Guards against accidental public repo creation. " + + "The gh repo create step is IRREVERSIBLE.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_createTool", + "Plan or execute creation of a new ecosystem tool repo. " + + "Dry-run (default): validates inputs, runs scaffold/create-tool.py to a temp dir to prove the scaffold " + + "works, lists all files that would be generated, computes the would-be registry entry, " + + "reports the standards-version the repo would start at, and describes the branch protection " + + "and required checks it would receive. Creates nothing. " + + "Apply (apply=true AND confirm=true): creates a real public GitHub repo (IRREVERSIBLE), " + + "scaffolds and bootstraps it, applies branch protection matching the type, " + + "registers it in registry.json, and opens+merges the meta-repo PR. " + + "Requires DEVTOOLS_META_ROOT for both modes. Requires GH_TOKEN with repo-creation scope for apply.", + inputSchema, + async ({ name, slug: slugArg, type, description, license, apply, confirm }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const slug = slugArg ?? slugify(name); + if (!SLUG_RE.test(slug)) { + return errorResponse( + new Error( + `Derived slug "${slug}" is not valid kebab-case. ` + + "Provide --slug explicitly or adjust the name.", + ), + ); + } + + const registry = await fetchRegistry(); + if (registry.some((e) => e.slug === slug)) { + return errorResponse( + new Error( + `Slug "${slug}" already exists in registry.json. ` + + "Choose a different slug or use syncRegistry to update the existing entry.", + ), + ); + } + + const standardsVersion = await fetchStandardsVersion(); + + // Read drift config to describe required workflows + let requiredWorkflows: string[] = []; + try { + const configRaw = readFileSync( + join(meta, "standards", "drift-checker.config.json"), + "utf-8", + ); + const config = JSON.parse(configRaw) as DriftConfig; + requiredWorkflows = config.types?.[type]?.required_workflows ?? []; + } catch { + // Drift config unavailable; report empty + } + + const wouldBeEntry = buildWouldBeEntry(name, slug, type, description, license); + + // Dry-run: scaffold to temp dir + const tmpBase = tmpdir(); + const tmpDir = mkdtempSync(join(tmpBase, "devtools-create-")); + let scaffoldedFiles: string[] = []; + let scaffoldError: string | null = null; + + try { + const result = runScaffold(meta, name, slug, type, description, license, tmpDir); + if (result.code === 0) { + const slugDir = join(tmpDir, slug); + scaffoldedFiles = listFiles(slugDir); + } else { + scaffoldError = result.stderr.trim() || result.stdout.trim(); + } + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + + if (!apply) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: false, + plan: { + name, + slug, + type, + description, + license, + githubRepo: `${META_OWNER}/${slug}`, + standardsVersionAtCreation: standardsVersion, + wouldBeRegistryEntry: wouldBeEntry, + scaffoldedFiles: scaffoldError + ? { error: scaffoldError } + : scaffoldedFiles, + branchProtection: { + description: + "main protection ruleset: block direct push, require PR + squash, " + + "empty bypass list", + requiredStatusChecks: requiredWorkflows, + }, + }, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply path: confirm + token required + if (!confirm) { + return errorResponse( + new Error( + "apply=true requires confirm=true. " + + "gh repo create is IRREVERSIBLE. Set confirm=true only when ready.", + ), + ); + } + + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error("GH_TOKEN or GITHUB_TOKEN with repo-creation scope is required for apply."), + ); + } + + if (scaffoldError) { + return errorResponse( + new Error(`Scaffold failed during dry-run pre-check: ${scaffoldError}`), + ); + } + + // STEP 1: Create GitHub repo (IRREVERSIBLE) + const ghCreateResult = spawnSync( + "gh", + ["repo", "create", `${META_OWNER}/${slug}`, "--public", "--confirm"], + { encoding: "utf-8", timeout: 60_000, env: { ...process.env, GH_TOKEN: ghToken } }, + ); + if (ghCreateResult.status !== 0) { + return errorResponse( + new Error( + `gh repo create failed: ${ghCreateResult.stderr.trim() || ghCreateResult.stdout.trim()}`, + ), + ); + } + + // STEP 2: Scaffold to a fresh temp dir + const scaffoldTmp = mkdtempSync(join(tmpdir(), "devtools-scaffold-")); + try { + const result = runScaffold(meta, name, slug, type, description, license, scaffoldTmp); + if (result.code !== 0) { + return errorResponse(new Error(`Scaffold failed: ${result.stderr.trim()}`)); + } + + const repoDir = join(scaffoldTmp, slug); + + // STEP 3: Bootstrap onto fresh repo main (direct push allowed before protection) + const gitSteps = [ + ["git", "init"], + ["git", "add", "."], + ["git", "commit", "-s", "-m", `chore: initial scaffold at standards-version ${standardsVersion}`], + ["git", "remote", "add", "origin", `https://x-access-token:${ghToken}@github.com/${META_OWNER}/${slug}.git`], + ["git", "push", "-u", "origin", "main"], + ]; + for (const [cmd, ...args] of gitSteps) { + const r = spawnSync(cmd, args, { cwd: repoDir, encoding: "utf-8", timeout: 60_000 }); + if (r.status !== 0) { + return errorResponse( + new Error(`Bootstrap step "${cmd} ${args[0]}" failed: ${r.stderr.trim()}`), + ); + } + } + } finally { + rmSync(scaffoldTmp, { recursive: true, force: true }); + } + + // STEP 4: Apply branch protection via GitHub Rulesets API + await githubWrite(`/repos/${META_OWNER}/${slug}/rulesets`, "POST", { + name: "main protection", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["refs/heads/main"], exclude: [] } }, + rules: [ + { type: "deletion" }, + { type: "non_fast_forward" }, + { type: "required_linear_history" }, + { + type: "pull_request", + parameters: { + required_approving_review_count: 0, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ["squash"], + }, + }, + ], + bypass_actors: [], + }); + + // STEP 5: Register in registry.json + sync + meta PR + const registryPath = join(meta, "registry.json"); + const registryRaw = readFileSync(registryPath, "utf-8"); + const registryData = JSON.parse(registryRaw) as Record[]; + registryData.push(wouldBeEntry); + writeFileSync(registryPath, JSON.stringify(registryData, null, 2) + "\n", "utf-8"); + + // Regenerate artifacts + const syncScript = join(meta, "scripts", "sync_from_registry.py"); + const syncResult = spawnSync(PYTHON, [syncScript], { encoding: "utf-8", timeout: 30_000 }); + if (syncResult.status !== 0) { + return errorResponse(new Error(`sync_from_registry.py failed: ${syncResult.stderr.trim()}`)); + } + + // Build meta PR + const branchName = `feat/register-${slug}`; + const refData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/git/ref/heads/main`, + ); + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: refData.object.sha, + }); + + const syncFiles = ["registry.json", "README.md", "CLAUDE.md", "docs/index.html"]; + for (const filePath of syncFiles) { + let content: string; + try { + content = readFileSync(join(meta, filePath), "utf-8"); + } catch { + continue; + } + let fileSha: string | undefined; + try { + const fd = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + ); + fileSha = fd.sha; + } catch { + // New file + } + const body: Record = { + message: `feat: register ${slug} [skip ci]`, + content: Buffer.from(content, "utf-8").toString("base64"), + branch: branchName, + }; + if (fileSha) body.sha = fileSha; + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + "PUT", + body, + ); + } + + const pr = await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls`, "POST", { + title: `feat: register ${name} (${slug})`, + body: `Register new tool repo ${META_OWNER}/${slug} via devtools_createTool.`, + head: branchName, + base: "main", + }); + + const prData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}`, + ); + await waitForMetaPR(pr.number, prData.head.sha); + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}/merge`, "PUT", { + merge_method: "squash", + }); + try { + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/git/refs/heads/${branchName}`, + "DELETE", + ); + } catch { + // Best-effort + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: true, + slug, + repo: `${META_OWNER}/${slug}`, + metaPrMerged: pr.number, + standardsVersion, + status: "created", + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/src/tools/syncRegistry.ts b/src/tools/syncRegistry.ts new file mode 100644 index 0000000..7ee06c2 --- /dev/null +++ b/src/tools/syncRegistry.ts @@ -0,0 +1,359 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const META_OWNER = "TMHSDigital"; +const META_REPO = "Developer-Tools-Directory"; +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +// Files that sync_from_registry.py regenerates +const SYNC_FILES = ["README.md", "CLAUDE.md", "docs/index.html"]; + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function runSync(meta: string, checkOnly: boolean): { stdout: string; stderr: string; code: number } { + const scriptPath = join(meta, "scripts", "sync_from_registry.py"); + const args = checkOnly ? [scriptPath, "--check"] : [scriptPath]; + const result = spawnSync(PYTHON, args, { encoding: "utf-8", timeout: 30_000 }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: result.status ?? 2 }; +} + +function computeEditDiff( + registry: Record[], + edit: Record>, +): Array<{ slug: string; field: string; from: unknown; to: unknown }> { + const diff: Array<{ slug: string; field: string; from: unknown; to: unknown }> = []; + for (const [slug, fields] of Object.entries(edit)) { + const entry = registry.find((e) => (e as { slug: string }).slug === slug) as Record | undefined; + if (!entry) continue; + for (const [field, newVal] of Object.entries(fields)) { + if (JSON.stringify(entry[field]) !== JSON.stringify(newVal)) { + diff.push({ slug, field, from: entry[field], to: newVal }); + } + } + } + return diff; +} + +async function waitForMetaPR(prNumber: number, headSha: string): Promise { + const checkPath = `/repos/${META_OWNER}/${META_REPO}/commits/${headSha}/check-runs`; + const REQUIRED = ["Validate registry.json", "Registry sync check", "Public-repo safety scan"]; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const runs = data.check_runs; + const allDone = runs.every((c) => c.status === "completed"); + const anyFailed = runs.some( + (c) => c.conclusion === "failure" || c.conclusion === "action_required", + ); + if (anyFailed) throw new Error(`A required check failed on meta PR #${prNumber}`); + const requiredDone = REQUIRED.every((name) => { + const run = runs.find((c) => c.name === name); + return run?.status === "completed" && run?.conclusion === "success"; + }); + if (allDone && requiredDone) return; + } catch (e) { + if (e instanceof Error && e.message.startsWith("A required check")) throw e; + } + } + throw new Error(`Timed out waiting for meta PR #${prNumber} checks`); +} + +async function openAndMergeMetaPR( + branchName: string, + title: string, + body: string, +): Promise { + const pr = await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls`, "POST", { + title, + body, + head: branchName, + base: "main", + }); + + const prData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}`, + ); + + await waitForMetaPR(pr.number, prData.head.sha); + + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}/merge`, "PUT", { + merge_method: "squash", + }); + + try { + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/git/refs/heads/${branchName}`, + "DELETE", + ); + } catch { + // Best-effort cleanup + } +} + +const inputSchema = { + edit: z + .record(z.string(), z.record(z.string(), z.unknown())) + .optional() + .describe( + "Registry field updates keyed by slug. Example: {\"steam-mcp\": {\"version\": \"1.1.0\"}}. " + + "Only updates existing entries. Slug must already exist in registry.json.", + ), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to apply edits to registry.json, run sync_from_registry.py, and open a meta-repo PR. " + + "Dry-run by default. Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_syncRegistry", + "Preview or apply registry.json field edits and regenerate derived artifacts (README, CLAUDE.md, docs/index.html). " + + "Boundary: updates EXISTING entries only. Rejects slugs not already in registry.json. " + + "Dry-run calls sync_from_registry.py --check and reports the diff without committing. " + + "Apply writes the edits, regenerates artifacts, and opens a meta-repo PR that is squash-merged when CI passes. " + + "Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + inputSchema, + async ({ edit, apply }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const registryPath = join(meta, "registry.json"); + let registryRaw: string; + try { + registryRaw = readFileSync(registryPath, "utf-8"); + } catch { + return errorResponse(new Error(`Cannot read registry.json from ${registryPath}`)); + } + + const registry = JSON.parse(registryRaw) as Record[]; + + // Validate all edited slugs exist + if (edit) { + for (const slug of Object.keys(edit)) { + if (!registry.some((e) => (e as { slug: string }).slug === slug)) { + return errorResponse( + new Error( + `Slug "${slug}" not found in registry.json. ` + + "syncRegistry only updates existing entries. Use createTool to add a new repo.", + ), + ); + } + } + } + + const editDiff = edit ? computeEditDiff(registry, edit) : []; + + if (!apply) { + // Dry-run: show field diff + run sync --check with edited registry + let syncCheckCode = -1; + let syncCheckMessage = "DEVTOOLS_META_ROOT not set; skipping sync check"; + + const originalContent = registryRaw; + let tempWritten = false; + try { + if (edit && editDiff.length > 0) { + // Apply edits to in-memory copy, write temporarily + const edited = registry.map((entry) => { + const slug = (entry as { slug: string }).slug; + if (edit[slug]) return { ...entry, ...edit[slug] }; + return entry; + }); + writeFileSync(registryPath, JSON.stringify(edited, null, 2) + "\n", "utf-8"); + tempWritten = true; + } + + const syncResult = runSync(meta, true); + syncCheckCode = syncResult.code; + syncCheckMessage = + syncCheckCode === 0 + ? "sync --check passed: artifacts are in sync with (edited) registry" + : `sync --check exit ${syncCheckCode}: artifacts would need regeneration`; + } finally { + if (tempWritten) writeFileSync(registryPath, originalContent, "utf-8"); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: false, + editDiff, + syncCheckPassed: syncCheckCode === 0, + syncCheckMessage, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply mode + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error("GH_TOKEN or GITHUB_TOKEN is required for apply mode."), + ); + } + + // Apply edits to registry.json on disk + if (edit && editDiff.length > 0) { + const edited = registry.map((entry) => { + const slug = (entry as { slug: string }).slug; + if (edit[slug]) return { ...entry, ...edit[slug] }; + return entry; + }); + writeFileSync(registryPath, JSON.stringify(edited, null, 2) + "\n", "utf-8"); + } + + // Regenerate artifacts + const syncResult = runSync(meta, false); + if (syncResult.code !== 0) { + return errorResponse( + new Error(`sync_from_registry.py failed (exit ${syncResult.code}): ${syncResult.stderr.trim()}`), + ); + } + + // Verify artifacts are now in sync + const verifyResult = runSync(meta, true); + if (verifyResult.code !== 0) { + return errorResponse( + new Error("sync_from_registry.py --check failed after regeneration. Aborting."), + ); + } + + // Collect changed file contents for GitHub API push + const changedFiles: Array<{ path: string; content: string }> = []; + for (const filePath of ["registry.json", ...SYNC_FILES]) { + try { + const content = readFileSync(join(meta, filePath), "utf-8"); + changedFiles.push({ path: filePath, content }); + } catch { + // File may not exist (e.g. docs/index.html in minimal checkout) + } + } + + // Create branch on meta-repo + const slugList = edit ? Object.keys(edit).join("-") : "resync"; + const branchName = `chore/sync-registry-${slugList}`; + const refData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/git/ref/heads/main`, + ); + const headSha = refData.object.sha; + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: headSha, + }); + + // Push each file to the branch + for (const { path: filePath, content } of changedFiles) { + let fileSha: string | undefined; + try { + const fileData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + ); + fileSha = fileData.sha; + } catch { + // File does not exist on remote yet (unlikely for these files) + } + + const encoded = Buffer.from(content, "utf-8").toString("base64"); + const body: Record = { + message: `chore: sync registry artifacts [skip ci]`, + content: encoded, + branch: branchName, + }; + if (fileSha) body.sha = fileSha; + + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + "PUT", + body, + ); + } + + const editSummary = + editDiff.length > 0 + ? editDiff.map((d) => `${d.slug}.${d.field}: ${JSON.stringify(d.from)} -> ${JSON.stringify(d.to)}`).join(", ") + : "no field edits (pure resync)"; + + await openAndMergeMetaPR( + branchName, + `chore: sync registry artifacts${edit ? ` (${Object.keys(edit).join(", ")})` : ""} [skip ci]`, + `Registry sync via devtools_syncRegistry.\n\nChanges: ${editSummary}`, + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: true, + editDiff, + filesUpdated: changedFiles.map((f) => f.path), + status: "merged", + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} From efcedaf6d71a697934d3ddc0540ae3d9cf25da95 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 17:31:02 -0400 Subject: [PATCH 11/14] fix: pages deploy, release workflow, docs site, and v0.2.0 version markers (#12) - Replace pages.yml cursor-plugin build_site.py with direct docs/ deploy - Remove release-doc-sync@v1 step; add package.json version bump to release.yml - Create docs/index.html with full 7-tool reference, quick start, env var docs - Move CHANGELOG [Unreleased] to [0.2.0] - 2026-05-24 (includes restampRepo entry) - Update ROADMAP Current to v0.2.0 - Pre-set README version badge to 0.2.0 Signed-off-by: fOuttaMyPaint --- .github/workflows/pages.yml | 25 +- .github/workflows/release.yml | 16 +- CHANGELOG.md | 4 + README.md | 4 +- ROADMAP.md | 2 +- docs/index.html | 517 ++++++++++++++++++++++++++++++++++ 6 files changed, 535 insertions(+), 33 deletions(-) create mode 100644 docs/index.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9e94fdd..8c5739e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,11 +4,7 @@ on: push: branches: [main] paths: - - "skills/**" - - "rules/**" - - "mcp-tools.json" - - "site.json" - - ".cursor-plugin/plugin.json" + - "docs/**" - "assets/**" workflow_dispatch: @@ -21,8 +17,7 @@ concurrency: cancel-in-progress: true jobs: - build-and-deploy: - + deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -30,22 +25,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Checkout site template - uses: actions/checkout@v6 - with: - repository: TMHSDigital/Developer-Tools-Directory - sparse-checkout: site-template - path: _template - - - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - run: pip install Jinja2 - - - name: Build site - run: python _template/site-template/build_site.py --repo-root . --out docs - - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c853ba..ebec0ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,14 +116,16 @@ jobs: ) with open(readme, 'w') as f: f.write(content) - " - - name: Sync release docs - if: steps.check.outputs.skip == 'false' - uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@v1 - with: - plugin-version: ${{ steps.new.outputs.version }} - previous-version: ${{ steps.current.outputs.version }} + pkg = 'package.json' + if os.path.exists(pkg): + with open(pkg) as f: + data = json.load(f) + data['version'] = new_version + with open(pkg, 'w') as f: + json.dump(data, f, indent=2) + f.write('\n') + " - name: Commit version bump to branch and open PR id: pr diff --git a/CHANGELOG.md b/CHANGELOG.md index 628d86e..1eec248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.2.0] - 2026-05-24 + ### Added +- `devtools_restampRepo`: preview or apply a standards-version restamp across fleet repos. Dry-run calls the canonical drift checker (`scripts/drift_check/cli.py`) to discover drifted files. Apply stamps files via Phase 1 Python scripts, opens per-repo branches and PRs, and squash-merges when CI passes. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. - `devtools_syncRegistry`: preview or apply `registry.json` field edits and regenerate derived artifacts (README.md, CLAUDE.md, docs/index.html). Update-only boundary: rejects slugs not already in the registry. Dry-run runs `sync_from_registry.py --check` against the edited registry without committing. Apply writes edits, regenerates, verifies with `--check`, and opens a meta-repo PR that is squash-merged when CI passes. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. - `devtools_createTool`: plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs `scaffold/create-tool.py` to a temp dir, lists generated files, reports the would-be registry entry and `STANDARDS_VERSION` at birth. Apply creates a real public GitHub repo (guarded by `confirm=true` and token with repo-creation scope), scaffolds, bootstraps, applies branch protection matching the type, and registers via meta-repo PR. Requires `DEVTOOLS_META_ROOT`. - `.gitattributes` with `text=auto` and `*.ts eol=lf` to eliminate CRLF phantom changes in git status. +- GitHub Pages documentation site at `docs/index.html` covering all 7 tools, quick start, and environment variable reference. ## [0.1.0] - 2026-05-24 diff --git a/README.md b/README.md index 3101aa0..82e4d6d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Developer Tools MCP -MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable read tools. +MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable tools. ![License: CC-BY-NC-ND-4.0](https://img.shields.io/badge/license-CC--BY--NC--ND--4.0-green) -![Version](https://img.shields.io/badge/version-0.1.0-blue) +![Version](https://img.shields.io/badge/version-0.2.0-blue) **v0.2.0 adds a write surface.** Three write tools ship alongside the four read tools. All write tools default to dry-run and require `DEVTOOLS_META_ROOT` and `GH_TOKEN`. The write surface is now complete; see [ROADMAP.md](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index 610533a..91cc6a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ # Roadmap -**Current:** v0.1.0 +**Current:** v0.2.0 ## v0.1.0 - Read-Only Core (shipped) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..da4d7bb --- /dev/null +++ b/docs/index.html @@ -0,0 +1,517 @@ + + + + + + Developer Tools MCP + + + + + + +
+
v0.2.0
+

Developer Tools MCP

+

MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable tools. Read and write the fleet from any MCP-enabled agent.

+
+
4Read tools
+
3Write tools
+
0Auth needed for reads
+
5 minCache TTL
+
+ +
+ +
+ + +
+

Quick Start

+
+ + + +
+
+
GH_TOKEN=your_token npx @tmhs/devtools-mcp
+

No install required. Pulls the latest published version. GH_TOKEN is optional for read tools against public repos.

+
+
+
git clone https://github.com/TMHSDigital/Developer-Tools-MCP.git
+cd Developer-Tools-MCP
+npm install
+npm run build
+GH_TOKEN=your_token node dist/index.js
+
+
+

Add to your Claude Desktop, Cursor, or other MCP client config:

+
{
+  "mcpServers": {
+    "devtools": {
+      "command": "npx",
+      "args": ["@tmhs/devtools-mcp"],
+      "env": {
+        "GH_TOKEN": "your_token_here",
+        "DEVTOOLS_META_ROOT": "/path/to/Developer-Tools-Directory"
+      }
+    }
+  }
+}
+

DEVTOOLS_META_ROOT is only required for write tools (restampRepo, syncRegistry, createTool).

+
+
+ + +
+

Tools

+ +

Read Tools — no token required for public repos

+ +
+
+ devtools_getRegistry + Read +
+

Return entries from registry.json with optional filtering. Use this to enumerate the ecosystem or look up a specific tool.

+ + + + + + + +
ParameterTypeDefaultDescription
typestringallFilter by cursor-plugin or mcp-server
statusstringallFilter by lifecycle status: experimental, beta, active, maintenance, deprecated, archived
slugstringReturn a single entry by its registry slug
+

All parameters are optional. Called with no arguments, returns every entry in the registry.

+
+ +
+
+ devtools_getFleetStatus + Read +
+

List all repos with their registry version, latest GitHub release tag, and a version-signal field showing whether each repo is current, behind, or ahead of the fleet standards version.

+ + + + + +
ParameterTypeDefaultDescription
include_standards_versionbooleantrueInclude the meta-repo STANDARDS_VERSION in the response envelope
+
+ +
+
+ devtools_checkDrift + Read +
+

Return drift findings for one or all repos: standards-version mismatches and missing required workflows. Fetches the drift policy from standards/drift-checker.config.json in the meta-repo at runtime, so it always uses the current policy without a local clone.

+
+

The canonical drift checker (scripts/drift_check/cli.py) is authoritative. This tool is a convenience reader for agents that cannot run Python locally. For write operations on drift, use devtools_restampRepo.

+
+ + + + + + +
ParameterTypeDefaultDescription
slugstringall reposCheck a single repo instead of the full fleet
verbosebooleanfalseInclude per-file details alongside the summary
+
+ +
+
+ devtools_inspectRepo + Read +
+

Detailed view of a single repo: GitHub metadata, open PRs, recent CI workflow runs, and the standards-version marker read from CLAUDE.md.

+ + + + + +
ParameterTypeRequiredDescription
slugstringrequiredRegistry slug of the repo to inspect (e.g. steam-mcp)
+
+ +

Write Tools — require DEVTOOLS_META_ROOT and GH_TOKEN

+
+
+ Dry-run by default. All write tools default to apply=false. No network mutations happen without an explicit apply: true. Always verify the dry-run output before applying. +
+
+ +
+
+ devtools_restampRepo + Write + Dry-run safe +
+

Preview or apply a standards-version restamp across fleet repos. Dry-run delegates to the canonical Python drift checker to discover which files need stamping. Apply creates per-repo branches, commits via the GitHub API, opens PRs, and squash-merges each one when CI passes.

+ + + + + + + +
ParameterTypeDefaultDescription
slugstringall reposRestamp only this repo instead of the full fleet
versionstringmeta-repo STANDARDS_VERSIONTarget standards version to stamp (default reads the current version from the meta-repo)
applybooleanfalseSet true to open real PRs and merge them
+

Dry-run output: list of repos with drifted files and the planned stamp value. Apply output: per-repo PR numbers and merge status.

+
+ +
+
+ devtools_syncRegistry + Write + Dry-run safe +
+

Preview or apply registry.json field edits and regenerate all derived artifacts (README.md, CLAUDE.md, docs/index.html). Apply opens a meta-repo PR that is squash-merged when CI passes.

+
+

Update-only boundary. This tool rejects slugs that are not already in registry.json. To add a new entry, use devtools_createTool.

+
+ + + + + + +
ParameterTypeDefaultDescription
editobjectMap of { slug: { field: value, ... } } describing the edits to apply. Omit to run a no-op dry-run that verifies the registry is in sync.
applybooleanfalseSet true to write edits to disk, regenerate artifacts, and open a meta-repo PR
+

Example edit: { "steam-mcp": { "status": "active", "version": "1.1.0" } }

+
+ +
+
+ devtools_createTool + Write + Irreversible on apply +
+

Plan or execute creation of a new ecosystem tool repo. Dry-run scaffolds the full file tree to a temp directory, reports the 22-file plan and the would-be registry entry, then cleans up. Apply creates a real public GitHub repo.

+
+

Double-confirm guard. apply=true alone is refused. You must also pass confirm=true. The gh repo create step creates a live public repo and cannot be undone. Read the dry-run output carefully before confirming.

+
+ + + + + + + + + + + +
ParameterTypeDefaultDescription
namestringrequiredHuman-readable tool name (e.g. My Cursor Plugin)
slugstringauto-derivedURL-safe identifier. Defaults to a kebab-case slug derived from name.
typestringcursor-plugincursor-plugin or mcp-server
descriptionstringrequiredOne-sentence description used in the registry and scaffold README
licensestringcc-by-nc-nd-4.0SPDX license identifier
applybooleanfalseSet true to execute creation (requires confirm=true)
confirmbooleanfalseSafety gate: must be true alongside apply=true to proceed
+

Apply steps: create public GitHub repo, scaffold 22 files, git init + push, apply branch protection, open meta-repo PR to register the new entry.

+
+
+ + +
+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + +
VariableStatusDescription
GH_TOKENStrongly recommendedGitHub personal access token. No scopes required for read tools against public repos. Without it, GitHub limits unauthenticated requests to 60 per hour per IP. A single full fleet call fans out to 20-30 requests. Required for all write tools.
GITHUB_TOKENAlternativeAccepted as a fallback if GH_TOKEN is not set.
DEVTOOLS_META_ROOTRequired for write toolsAbsolute path to a local Developer-Tools-Directory checkout. When set, registry.json, STANDARDS_VERSION, and the drift config are read from disk instead of GitHub. Required for all write tools: restampRepo, syncRegistry, createTool.
+

Copy .env.example to .env and fill in GH_TOKEN before running locally.

+
+ + +
+

Safety Posture

+
+
+

Network safety

+
    +
  • Write tools default to apply=false. No network mutations without explicit opt-in.
  • +
  • createTool apply requires both apply=true AND confirm=true plus a token with repo-creation scope.
  • +
  • No secrets are committed. Tokens come from environment variables only.
  • +
  • Rate-limit errors name GH_TOKEN with instructions on how to obtain one.
  • +
+
+
+

Data safety

+
    +
  • No hardcoded paths. All GitHub reads use the public API or raw content URLs.
  • +
  • All GitHub API and raw file responses are cached in memory with a 5-minute TTL.
  • +
  • devtools_syncRegistry is update-only; it cannot add new entries to the registry.
  • +
  • devtools_createTool dry-run scaffolds to a temp directory and cleans up; it never writes to the working tree.
  • +
+
+
+
+ + +
+

Development

+
npm install
+npm run build
+npm test
+

Tests use vitest with mocked fetch responses. No live API calls are made in CI. See CONTRIBUTING.md for guidelines.

+
+ +
+ + + + + + + From 33b73e7a34834637d6422ecdb2b5a088b1138da5 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 17:34:18 -0400 Subject: [PATCH 12/14] fix: delete stale release branch before push to prevent non-fast-forward rejection (#13) Signed-off-by: fOuttaMyPaint --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebec0ff..c86356e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,7 @@ jobs: echo "no_changes=true" >> "$GITHUB_OUTPUT" else git commit -m "chore: bump version to ${new_version} [skip ci]" + git push origin ":$branch" 2>/dev/null || true git push origin "$branch" pr_num=$(gh pr create \ From 4fc81c9a7c28c338d1c9a5baec8baec4908a22f2 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 17:37:18 -0400 Subject: [PATCH 13/14] fix: extract PR number from gh pr create URL output; --json unsupported on runner (#14) Signed-off-by: fOuttaMyPaint --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c86356e..d1be09d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,12 +149,12 @@ jobs: git push origin ":$branch" 2>/dev/null || true git push origin "$branch" - pr_num=$(gh pr create \ + pr_url=$(gh pr create \ --base main \ --head "$branch" \ --title "chore: bump version to ${new_version} [skip ci]" \ - --body "Automated version bump to ${new_version}." \ - --json number --jq '.number') + --body "Automated version bump to ${new_version}.") + pr_num=$(echo "$pr_url" | grep -oE '[0-9]+$') echo "pr_num=$pr_num" >> "$GITHUB_OUTPUT" echo "branch=$branch" >> "$GITHUB_OUTPUT" From 196847cd000ff7fb468b3373876eb21e3bbf567b Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 24 May 2026 17:48:23 -0400 Subject: [PATCH 14/14] fix: simplify release workflow to tag from package.json; bump package.json to 0.2.0 (#16) The previous release workflow created version-bump PRs using GITHUB_TOKEN. GitHub blocks workflow triggers on GITHUB_TOKEN-created PRs, so the required Ecosystem drift check never ran, keeping the PR permanently blocked. New approach: developers bump package.json as part of their feature/fix PRs. The release workflow reads the version, checks if the tag exists, and if not tags + creates a GitHub Release. No PR creation, no polling, no race conditions. Also bumps package.json to 0.2.0 so v0.2.0 is cut when this merges. Signed-off-by: fOuttaMyPaint --- .github/workflows/release.yml | 175 ++++------------------------------ package.json | 4 +- 2 files changed, 18 insertions(+), 161 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1be09d..4146a90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,15 +7,14 @@ on: permissions: contents: write - pull-requests: write concurrency: group: release cancel-in-progress: false jobs: - version-and-release: - name: Bump version, tag, and release + tag-and-release: + name: Tag and release runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: @@ -25,181 +24,39 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Get current version - id: current + - name: Read version from package.json + id: ver run: | version=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Current version: $version" - - - name: Determine bump type from commits - id: bump - run: | - last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$last_tag" ]; then - commits=$(git log --oneline --format="%s") - else - commits=$(git log "$last_tag"..HEAD --oneline --format="%s") - fi - - echo "Commits since last release:" - echo "$commits" - - bump="patch" - if echo "$commits" | grep -qiE "^(feat|feature)(\(.+\))?!:|BREAKING CHANGE"; then - bump="major" - elif echo "$commits" | grep -qiE "^(feat|feature)(\(.+\))?:"; then - bump="minor" - fi - - echo "bump=$bump" >> "$GITHUB_OUTPUT" - echo "Bump type: $bump" - - - name: Compute new version - id: new - run: | - current="${{ steps.current.outputs.version }}" - bump="${{ steps.bump.outputs.bump }}" - - IFS='.' read -r major minor patch <<< "$current" - - # Initial release: when there is no prior tag, hold version at the - # value already in the manifest. The first release is cut at the - # scaffolded version (typically 0.1.0) instead of bumping to 0.2.0 - # on the first feat: commit. After the first tag exists, normal - # conventional-commit-driven bumping resumes. - last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$last_tag" ]; then - new_version="$current" - else - case "$bump" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - esac - new_version="$major.$minor.$patch" - fi - - echo "version=$new_version" >> "$GITHUB_OUTPUT" - echo "New version: $new_version" + echo "Version from package.json: $version" - name: Check if tag already exists id: check run: | - new_version="${{ steps.new.outputs.version }}" - if git rev-parse "v$new_version" >/dev/null 2>&1; then + version="${{ steps.ver.outputs.version }}" + if git rev-parse "v$version" >/dev/null 2>&1; then echo "skip=true" >> "$GITHUB_OUTPUT" - echo "Tag v$new_version already exists, skipping" + echo "Tag v$version already exists, skipping release" else echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Tag v$version does not exist, proceeding" fi - - name: Update version files - if: steps.check.outputs.skip == 'false' - env: - NEW_VERSION: ${{ steps.new.outputs.version }} - OLD_VERSION: ${{ steps.current.outputs.version }} - run: | - python3 -c " - import json, os - - new_version = os.environ['NEW_VERSION'] - old_version = os.environ['OLD_VERSION'] - - readme = 'README.md' - if os.path.exists(readme): - with open(readme) as f: - content = f.read() - content = content.replace( - f'version-{old_version}-blue', - f'version-{new_version}-blue' - ) - with open(readme, 'w') as f: - f.write(content) - - pkg = 'package.json' - if os.path.exists(pkg): - with open(pkg) as f: - data = json.load(f) - data['version'] = new_version - with open(pkg, 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - " - - - name: Commit version bump to branch and open PR - id: pr + - name: Create and push tags if: steps.check.outputs.skip == 'false' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - new_version="${{ steps.new.outputs.version }}" - branch="chore/release-v${new_version}" + version="${{ steps.ver.outputs.version }}" + IFS='.' read -r major minor _patch <<< "$version" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -b "$branch" - git add -A - - if git diff --cached --quiet; then - echo "No version file changes to commit" - echo "no_changes=true" >> "$GITHUB_OUTPUT" - else - git commit -m "chore: bump version to ${new_version} [skip ci]" - git push origin ":$branch" 2>/dev/null || true - git push origin "$branch" - - pr_url=$(gh pr create \ - --base main \ - --head "$branch" \ - --title "chore: bump version to ${new_version} [skip ci]" \ - --body "Automated version bump to ${new_version}.") - pr_num=$(echo "$pr_url" | grep -oE '[0-9]+$') - - echo "pr_num=$pr_num" >> "$GITHUB_OUTPUT" - echo "branch=$branch" >> "$GITHUB_OUTPUT" - echo "no_changes=false" >> "$GITHUB_OUTPUT" - echo "Opened PR #${pr_num} for version bump" - - # Poll for required drift check (max 5 min) - for i in $(seq 1 30); do - sleep 10 - state=$(gh pr view "$pr_num" \ - --json statusCheckRollup \ - --jq '[.statusCheckRollup[] | select(.name == "Ecosystem drift check")] | first | .conclusion // .status' \ - 2>/dev/null || echo "pending") - echo "Attempt $i: drift check state = $state" - if [ "$state" = "SUCCESS" ]; then - echo "Drift check passed" - break - elif [ "$state" = "FAILURE" ] || [ "$state" = "ERROR" ]; then - echo "::error::Drift check failed on version-bump PR #${pr_num}" - exit 1 - fi - done - - gh pr merge "$pr_num" --squash --delete-branch - echo "Merged PR #${pr_num}" - fi - - - name: Sync local git to merged main - if: steps.check.outputs.skip == 'false' && steps.pr.outputs.no_changes != 'true' - run: | - git fetch origin main - git checkout main - git reset --hard origin/main - - - name: Create and push tags - if: steps.check.outputs.skip == 'false' - run: | - new_version="${{ steps.new.outputs.version }}" - IFS='.' read -r major minor _patch <<< "$new_version" - git tag "v$new_version" + git tag "v$version" git tag -f "v$major" git tag -f "v$major.$minor" - git push origin "v$new_version" + git push origin "v$version" git push origin "v$major" --force git push origin "v$major.$minor" --force @@ -208,6 +65,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "v${{ steps.new.outputs.version }}" \ - --title "v${{ steps.new.outputs.version }}" \ + gh release create "v${{ steps.ver.outputs.version }}" \ + --title "v${{ steps.ver.outputs.version }}" \ --generate-notes diff --git a/package.json b/package.json index 0dcd3e5..6cf3de2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tmhs/devtools-mcp", - "version": "0.1.0", - "description": "MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable read tools.", + "version": "0.2.0", + "description": "MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable tools.", "type": "module", "main": "dist/index.js", "bin": {