From 9ac35686cbe4347377f333c3f843edde300ae562 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 11 Sep 2026 11:31:49 -0400 Subject: [PATCH 1/2] Pin the MCP server the plugin delivers, and review both server files .mcp.json ran `uvx idfkit-mcp` with no version, so every editor got whatever was newest on the day and a release reached users the moment it published, reviewed by nobody (feature 004 of the unification, FR-015). It now pins idfkit-mcp@0.9.3, the newest release. The user still types no version. .lsp.json is unchanged: idfkit-lsp has never been published to PyPI, so there is no version to pin and the entry resolves nothing today (#17). The consumer register records that as a not-yet lag rather than pinning something that does not exist. CODEOWNERS puts both files under review (FR-016), and the README and AGENTS.md no longer teach the unpinned form. --- .github/CODEOWNERS | 9 +++++++++ .mcp.json | 2 +- AGENTS.md | 28 +++++++++++++++++++++++++++- README.md | 11 ++++++++--- 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6336ecb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# The two files that decide what every user of this plugin runs. .mcp.json pins the idfkit-mcp +# release an editor acquires, and .lsp.json the language server. Changing either changes what +# reaches a person's editor, so feature 004 of the unification (FR-016) requires the change to be +# reviewed, not only detected by the consumer register's self-check. +# +# Enforcement needs branch protection with "Require review from Code Owners" on main. + +/.mcp.json @samuelduchesne +/.lsp.json @samuelduchesne diff --git a/.mcp.json b/.mcp.json index 7b504e1..58680b2 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "idfkit": { "command": "uvx", - "args": ["idfkit-mcp"] + "args": ["idfkit-mcp@0.9.3"] } } } diff --git a/AGENTS.md b/AGENTS.md index ff3380a..a449c3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ idfkit package, not here. | Sub-agents | `agents/*.md` | Yes | | Slash commands | `commands/*.md` | Yes | | Context hooks | `hooks/hooks.json`, `scripts/*.sh` | Yes | -| MCP server wiring | `.mcp.json` (`uvx idfkit-mcp`) | Yes | +| MCP server wiring | `.mcp.json` (`uvx idfkit-mcp@`, pinned) | Yes, by review | +| LSP wiring | `.lsp.json` (`uvx --from idfkit-lsp`, unpinned until idfkit-lsp publishes) | Yes, by review | | Claude plugin / marketplace | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` | Yes | ## Discovery contract @@ -58,3 +59,28 @@ Keep the two surfaces separate: tools. Do not duplicate library reference content into the workflow skills, and do not route library references through the MCP — that coupling is intentionally avoided so library authoring works without spinning up the server. + +## Delivery paths and the consumer register + +This plugin hands idfkit to a person's editor, so what it pins is what they run. +It is `idfkit-plugin` in the consumer register, `governance/consumers.toml` in +idfkit-conformance (feature 004 of the unification), with role `delivers`, +depending on `idfkit-mcp` and `idfkit-lsp`, and adopting in wave 2 after them. + +- **`.mcp.json` pins idfkit-mcp exactly** (`idfkit-mcp@X.Y.Z`). Never remove the + version: an unpinned `uvx idfkit-mcp` runs whatever is newest on the day, so a + release would reach users unreviewed. The user still types no version; the + level is committed here. The idfkit level follows from that release's own pin. +- **`.lsp.json` cannot be pinned yet.** idfkit-lsp has never been published to + PyPI, so today that entry resolves nothing on a clean machine. The register + records it as a `not-yet` lag on idfkit/idfkit-plugin#17. Pin it as + `uvx --from idfkit-lsp==X.Y.Z` in the change that follows the first release. +- **Both files are CODEOWNERS-reviewed**, because changing either changes what + every user runs. +- **The self-check.** The `consumer-register` job in `tests.yml` calls + `check-consumer.yml` at a pinned governance tag and fails if the register no + longer describes this repository. +- **Adoption.** `bump-idfkit.yml` refuses to adopt an idfkit level until + idfkit-mcp has published a release pinning it, naming it, then moves the pin + in `.mcp.json` and opens a pull request. `rehearse-candidate.yml` rehearses + through the two servers, since this repository holds no code that calls idfkit. diff --git a/README.md b/README.md index a8f4816..26c7b2a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Claude Code, Cursor, Copilot, Gemini, Codex, and any MCP-capable client. | Layer | What it does | |---|---| -| **MCP server** (`uvx idfkit-mcp`) | Tools and `idfkit://…` resources to load, query, edit, validate, and simulate IDF / epJSON models. | +| **MCP server** (`uvx idfkit-mcp@`, pinned in `.mcp.json`) | Tools and `idfkit://…` resources to load, query, edit, validate, and simulate IDF / epJSON models. | | **`developing-with-idfkit` skill** | Discovers the idfkit installed in your project and loads **version-matched** reference docs for writing Python against the library. | | **Workflow skills** | Task playbooks that orchestrate the MCP tools: `simulate`, `validate`, `new-model`, `load-model`, `energy-audit`, `weather`, `upgrade-version`, `idf-conventions`, `docs`. | | **Agents** | Focused sub-agents: `energy-modeler`, `envelope-analyst`, `hvac-designer`. | @@ -91,16 +91,21 @@ From inside a Codex session, run `$skill-installer` and point it at ### MCP server — any MCP-capable client -For Claude Desktop, ChatGPT, or other MCP clients, add the server directly: +For Claude Desktop, ChatGPT, or other MCP clients, add the server directly, pinned +to the version the plugin's own [`.mcp.json`](.mcp.json) names: ```json { "mcpServers": { - "idfkit": { "command": "uvx", "args": ["idfkit-mcp"] } + "idfkit": { "command": "uvx", "args": ["idfkit-mcp@"] } } } ``` +Pin a version. Without one, `uvx` runs whatever is newest on the day, so a release +reaches your client the moment it publishes, reviewed by nobody in between. The +plugin itself always pins, and you type no version when you install it. + ## Requirements - **Python 3.10+** with **idfkit >= 0.13** installed in your project — required for From ab791c49649f34035839c54c6d75ed18fb30784b Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 11 Sep 2026 11:31:49 -0400 Subject: [PATCH 2/2] Check the plugin against the register, and adopt after its servers The consumer-register job calls check-consumer.yml at governance-2026.18, which is not cut yet, so it fails until it is. bump-idfkit.yml is dispatched in wave 2: it refuses an idfkit level until idfkit-mcp has a release pinning it, naming idfkit-mcp (FR-020), then moves .mcp.json to that release. The language server cannot move while idfkit-lsp is unpublished, and the pull request says so rather than pretending. decline_reason records a deliberate lag (FR-024). rehearse-candidate.yml rehearses through the two servers. --- .github/workflows/bump-idfkit.yml | 169 +++++++++++++++++++++++ .github/workflows/rehearse-candidate.yml | 37 +++++ .github/workflows/tests.yml | 10 ++ 3 files changed, 216 insertions(+) create mode 100644 .github/workflows/bump-idfkit.yml create mode 100644 .github/workflows/rehearse-candidate.yml diff --git a/.github/workflows/bump-idfkit.yml b/.github/workflows/bump-idfkit.yml new file mode 100644 index 0000000..40ccde4 --- /dev/null +++ b/.github/workflows/bump-idfkit.yml @@ -0,0 +1,169 @@ +name: bump-idfkit + +# Adopt a published idfkit level, as a pull request (feature 004 of the unification, US4). +# +# idfkit's notify-downstream.yml dispatches this in wave 2, after the consumers that resolve idfkit +# directly, because the plugin reaches idfkit only through the two servers it hands to an editor. +# It never pins idfkit. It pins idfkit-mcp in .mcp.json, and idfkit follows from that release. +# +# ORDER IS ENFORCED HERE, NOT ASSUMED (FR-020). idfkit-conformance's tools/adoption.py check-order +# rejects the adoption until each server the plugin pins has a release on the new level, naming the +# server it waits on. For idfkit-mcp that is a hard stop: this run fails red, and the adoption +# follows once idfkit-mcp releases. +# +# THE LANGUAGE SERVER IS THE ONE HONEST EXCEPTION. idfkit-lsp has never been published to PyPI +# (idfkit/idfkit-plugin#17), so check-order will always name it, and .lsp.json has no version to +# move to. Refusing the whole adoption for that would freeze the MCP server behind a gap this run +# cannot close, so the MCP half proceeds and the pull request says in plain words that the LSP half +# is unchanged and why. It does not pretend the LSP was adopted, and it does not close the +# register's lag, which is about exactly that. +# +# DECLINING. Give `decline_reason` and nothing is bumped: the run records a deliberate lag in the +# consumer register instead (FR-024). The reason states a policy, never a version. + +on: + workflow_dispatch: + inputs: + idfkit_version: + description: The released idfkit version to adopt, with or without a leading v. + required: true + type: string + decline_reason: + description: Decline the level instead, recording this policy as a deliberate lag. + required: false + type: string + default: "" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: bump-idfkit + cancel-in-progress: false + +env: + CONSUMER: idfkit-plugin + GOVERNANCE_LEVEL: governance-2026.18 + +jobs: + bump: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Check out the consumer register + uses: actions/checkout@v4 + with: + repository: idfkit/idfkit-conformance + ref: refs/tags/${{ env.GOVERNANCE_LEVEL }} + path: governance + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Normalize version + id: ver + env: + RAW_VERSION: ${{ inputs.idfkit_version }} + run: | + set -euo pipefail + version="${RAW_VERSION#v}" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "previous=$(python3 -c 'import json;print(json.load(open(".mcp.json"))["mcpServers"]["idfkit"]["args"][0])')" >> "$GITHUB_OUTPUT" + + - name: Decline, and record why + if: inputs.decline_reason != '' + env: + GH_TOKEN: ${{ secrets.CONFORMANCE_DISPATCH_TOKEN }} + REASON: ${{ inputs.decline_reason }} + run: | + set -euo pipefail + command=(gh workflow run record-adoption.yml -R idfkit/idfkit-conformance + -f consumer="$CONSUMER" -f library=python -f outcome=decline -f reason="$REASON") + if [ -z "$GH_TOKEN" ]; then + echo "::error title=Decline not recorded::No CONFORMANCE_DISPATCH_TOKEN, so the deliberate lag cannot be recorded from here. Run: ${command[*]}" + exit 1 + fi + "${command[@]}" + + # check-order names every provider that has not released on this level. idfkit-mcp is a + # hard stop; idfkit-lsp is reported and recorded, for the reason in the header. + - name: Refuse to adopt before idfkit-mcp has + id: order + if: inputs.decline_reason == '' + run: | + set +e + python governance/tools/adoption.py check-order --consumer "$CONSUMER" --library python \ + --level "${{ steps.ver.outputs.version }}" 2> "$RUNNER_TEMP/order.txt" + cat "$RUNNER_TEMP/order.txt" + if grep -q "provider idfkit-mcp" "$RUNNER_TEMP/order.txt"; then + echo "::error title=Out of order::idfkit-mcp has published no release on idfkit ${{ steps.ver.outputs.version }}. The plugin adopts after it (FR-020)." + exit 1 + fi + if grep -q "provider idfkit-lsp" "$RUNNER_TEMP/order.txt"; then + echo "lsp_waiting=true" >> "$GITHUB_OUTPUT" + echo "::warning title=LSP unchanged::idfkit-lsp has no release on this level, so .lsp.json does not move (idfkit/idfkit-plugin#17)." + fi + + - name: Find the idfkit-mcp release on this level + id: release + if: inputs.decline_reason == '' + env: + LEVEL: ${{ steps.ver.outputs.version }} + run: | + python - <<'PY' >> "$GITHUB_OUTPUT" + import os, sys + sys.path.insert(0, "governance/tools") + from adoption import _fetch_json, pypi_released_on + release = pypi_released_on(_fetch_json)("idfkit-mcp", os.environ["LEVEL"]) + if release is None: + sys.exit("check-order allowed the adoption, and no idfkit-mcp release pins this level. Stop.") + print(f"release={release}") + PY + + # Text substitution rather than a JSON round trip, so the file keeps its formatting and the + # diff is the one version string that changed. + - name: Pin the MCP server to that release + if: inputs.decline_reason == '' + env: + RELEASE: ${{ steps.release.outputs.release }} + run: | + python - <<'PY' + import os, re + from pathlib import Path + path = Path(".mcp.json") + text, count = re.subn(r'"idfkit-mcp(@[^"]*)?"', f'"idfkit-mcp@{os.environ["RELEASE"]}"', path.read_text()) + if count != 1: + raise SystemExit(f"expected one idfkit-mcp entry in .mcp.json, found {count}") + path.write_text(text) + PY + + - name: Run the tests + id: tests + if: inputs.decline_reason == '' + continue-on-error: true + run: python -m unittest tests.discovery.test_discover -v + + - name: Open the pull request + if: inputs.decline_reason == '' + uses: peter-evans/create-pull-request@v7 + with: + branch: bot/bump-idfkit-${{ steps.ver.outputs.version }} + title: "Deliver idfkit-mcp ${{ steps.release.outputs.release }} (idfkit ${{ steps.ver.outputs.version }})" + commit-message: "Deliver idfkit-mcp ${{ steps.release.outputs.release }} (idfkit ${{ steps.ver.outputs.version }})" + reviewers: samuelduchesne + body: | + idfkit ${{ steps.ver.outputs.version }} was released, and idfkit-mcp ${{ steps.release.outputs.release }} + pins it, so `.mcp.json` moves from `${{ steps.ver.outputs.previous }}` to + `idfkit-mcp@${{ steps.release.outputs.release }}`. Every user who installs or updates the + plugin after this merges runs that server. + + - Discovery tests: `${{ steps.tests.outcome }}` + - Language server: ${{ steps.order.outputs.lsp_waiting == 'true' && 'unchanged. idfkit-lsp has no release on this level, and has never been published to PyPI, so .lsp.json has no version to move to (idfkit/idfkit-plugin#17). The register lag for that stays open.' || 'idfkit-lsp has a release on this level; move .lsp.json to it in this pull request before merging.' }} + + Opened by idfkit's notify-downstream.yml; it never merges itself. diff --git a/.github/workflows/rehearse-candidate.yml b/.github/workflows/rehearse-candidate.yml new file mode 100644 index 0000000..a9c7ed7 --- /dev/null +++ b/.github/workflows/rehearse-candidate.yml @@ -0,0 +1,37 @@ +# Rehearse the plugin against an unpublished idfkit build (feature 004, User Story 2). +# +# The plugin holds no code that calls idfkit. A candidate reaches a user only inside an idfkit-mcp +# or idfkit-lsp release, so its register entry rehearses `through-provider`: the called workflow +# builds the candidate and names the two servers whose rehearsals this is. It exists so idfkit's +# rehearse-all.yml can address every consumer the same way. + +name: rehearse-candidate + +on: + workflow_dispatch: + inputs: + # Declared because both libraries' rehearse-all.yml dispatch every consumer with the same two + # inputs, and GitHub rejects a dispatch carrying an input the workflow does not declare. This + # consumer resolves one library, so the choice has one option. + library: + description: The library being rehearsed. This consumer resolves python only. + required: true + type: choice + options: [python] + default: python + candidate_ref: + description: A commit, branch or tag in idfkit/idfkit to rehearse against. Required. + required: true + type: string + +permissions: + contents: read + +jobs: + rehearse: + uses: idfkit/idfkit-conformance/.github/workflows/rehearse.yml@governance-2026.18 + with: + governance_level: governance-2026.18 + consumer: idfkit-plugin + library: ${{ inputs.library }} + candidate_ref: ${{ inputs.candidate_ref }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da0f757..b762042 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,3 +21,13 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run discovery tests run: python -m unittest tests.discovery.test_discover -v + + consumer-register: + # This repository is `idfkit-plugin` in the consumer register, governance/consumers.toml in + # idfkit-conformance (feature 004). The called workflow asserts that the register still + # describes it: .mcp.json pins idfkit-mcp exactly, and .lsp.json is the recorded, unpinned + # exception while idfkit-lsp is unpublished. The tag is written twice because a called workflow + # cannot see its own ref; the called workflow refuses the run unless they match. + uses: idfkit/idfkit-conformance/.github/workflows/check-consumer.yml@governance-2026.18 + with: + governance_level: governance-2026.18