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/.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 c081a4b..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,34 +17,17 @@ concurrency:
cancel-in-progress: true
jobs:
- build-and-deploy:
-
+ deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
-
- - name: Checkout site template
- uses: actions/checkout@v4
- with:
- repository: TMHSDigital/Developer-Tools-Directory
- sparse-checkout: site-template
- path: _template
-
- - uses: actions/setup-python@v5
- 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/checkout@v6
- - uses: actions/configure-pages@v5
+ - uses: actions/configure-pages@v6
- - uses: actions/upload-pages-artifact@v4
+ - uses: actions/upload-pages-artifact@v5
with:
path: docs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4568819..4146a90 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,8 +13,8 @@ concurrency:
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:
@@ -24,131 +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
+ - name: Create and push tags
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)
- "
+ version="${{ steps.ver.outputs.version }}"
+ IFS='.' read -r major minor _patch <<< "$version"
- - 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 }}
-
- - name: Commit version bump
- if: steps.check.outputs.skip == 'false'
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add -A
- if git diff --cached --quiet; then
- echo "No changes to commit"
- else
- git commit -m "chore: bump version to ${{ steps.new.outputs.version }} [skip ci]"
- git push origin main
- fi
-
- - 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
@@ -157,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/.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."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 971451f..1eec248 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ 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]
+
+## [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
### Added
diff --git a/README.md b/README.md
index 0370fb7..82e4d6d 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,17 @@
# 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.

-
+
-**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 1299f0c..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)
@@ -11,20 +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 (not yet built)
+## v0.2.0 - Write Surface (COMPLETE)
-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. All require `DEVTOOLS_META_ROOT` (local meta-repo clone) and `GH_TOKEN`.
-**Planned tools:**
+| 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 (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.
+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/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
+
+
+
+
+
+
+
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.
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.
+
+
Parameter
Type
Default
Description
+
+
type
string
all
Filter by cursor-plugin or mcp-server
+
status
string
all
Filter by lifecycle status: experimental, beta, active, maintenance, deprecated, archived
+
slug
string
—
Return 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.
+
+
Parameter
Type
Default
Description
+
+
include_standards_version
boolean
true
Include 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.
+
+
+
Parameter
Type
Default
Description
+
+
slug
string
all repos
Check a single repo instead of the full fleet
+
verbose
boolean
false
Include 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.
+
+
Parameter
Type
Required
Description
+
+
slug
string
required
Registry 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.
+
+
Parameter
Type
Default
Description
+
+
slug
string
all repos
Restamp only this repo instead of the full fleet
+
version
string
meta-repo STANDARDS_VERSION
Target standards version to stamp (default reads the current version from the meta-repo)
+
apply
boolean
false
Set 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.
+
+
+
Parameter
Type
Default
Description
+
+
edit
object
—
Map of { slug: { field: value, ... } } describing the edits to apply. Omit to run a no-op dry-run that verifies the registry is in sync.
+
apply
boolean
false
Set true to write edits to disk, regenerate artifacts, and open a meta-repo PR
+ 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.
+
+
+
Parameter
Type
Default
Description
+
+
name
string
required
Human-readable tool name (e.g. My Cursor Plugin)
+
slug
string
auto-derived
URL-safe identifier. Defaults to a kebab-case slug derived from name.
+
type
string
cursor-plugin
cursor-plugin or mcp-server
+
description
string
required
One-sentence description used in the registry and scaffold README
+
license
string
cc-by-nc-nd-4.0
SPDX license identifier
+
apply
boolean
false
Set true to execute creation (requires confirm=true)
+
confirm
boolean
false
Safety 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
+
+
+
Variable
Status
Description
+
+
+
+
GH_TOKEN
+
Strongly recommended
+
GitHub 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_TOKEN
+
Alternative
+
Accepted as a fallback if GH_TOKEN is not set.
+
+
+
DEVTOOLS_META_ROOT
+
Required for write tools
+
Absolute 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.
+
+
+
+
+
+
+
+
+
+
diff --git a/mcp-tools.json b/mcp-tools.json
index 5c819d2..73923d3 100644
--- a/mcp-tools.json
+++ b/mcp-tools.json
@@ -18,5 +18,20 @@
"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"
+ },
+ {
+ "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/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": {
diff --git a/src/index.ts b/src/index.ts
index 210ccaf..84953a4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,9 @@ 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";
+import { register as registerSyncRegistry } from "./tools/syncRegistry.js";
+import { register as registerCreateTool } from "./tools/createTool.js";
const server = new McpServer({
name: "devtools-mcp",
@@ -17,6 +20,9 @@ registerGetRegistry(server);
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 291da33..ca22048 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,11 +169,115 @@ 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");
});
});
+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_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;
+ 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/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/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/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/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/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);
+ }
+ },
+ );
+}
diff --git a/src/utils/github.ts b/src/utils/github.ts
index c35b15c..4d07130 100644
--- a/src/utils/github.ts
+++ b/src/utils/github.ts
@@ -127,7 +127,43 @@ 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();
}
+
+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;
+}