mcp: plugin_add takes a digest the MCP surface cannot discover, so an agent cannot enable an installed package #100

Closed
opened 2026-09-04 12:13:53 +02:00 by buildagent · 2 comments
Member

The gap

plugin_add is the only plugin tool on the MCP surface. It requires exactly one of digest (a package already in the store) or file (a path to a signed .cip). Nothing on that surface can produce either value.

An agent session with the MCP tools and no shell therefore cannot enable a package that is sitting installed and approved-elsewhere in the machine-wide store, even though plugin_add exists precisely to do that. The tool that acts has no companion tool that discovers.

Measured, 2026-09-04, v0.26.1 (4555887)

On this machine the XAML reference package is installed in the user-scoped store and enabled for E:\code\rust\code-index:

package_store  C:\Users\dhoyer\.code-index\packages
installed      sha256:584fe7183b234e7133fa7d257cc8a8d687f38bfb43d235cfda657ba13b1186dd  de.h-dv.xaml 0.1.0  languages=de.h-dv.xaml/xaml  requests=bridge_source

The store holds the bytes machine-wide; the approval record is per project key, and only one record exists. Enabling it for a second project is a pure grant — no download, no re-install. The operator asked how to have an agent do that in each project, and the honest answer was "it cannot, from MCP alone."

What the MCP surface does report, and why it does not close this. project_overview.plugin_activation carries active_activation_digest and pending_activation_digest — the activation identity of the generation this project is serving, not a package install identity, and in a project that has enabled nothing there is no active generation to name. The block already carries packages_requested_not_installed, package_duplicate_ids and package_set_consulted, so the store is demonstrably in reach at the point the block is built; the inventory simply is not in it.

What the CLI has and MCP does not. plugin status is read-only (cmd_status: "READ ONLY, so no disclosure and no write") and its installed lines come from store.installed() — the machine-wide store, independent of this project's approval record. That is the discovery answer, and it is reachable only through a shell.

Why this is a product defect and not a workaround

The two available workarounds are both wrong in the way this repository's own guides call out:

  1. Hardcode the digest into a CLAUDE.md or a prompt. It works until the package version moves, at which point every copy of the constant becomes a digest_mismatch refusal — and a stale pin looks like a stale download.
  2. Shell out to plugin status. Correct today, but it makes the shell a hard dependency for a capability the MCP surface advertises, and it is exactly the shell fallback CLAUDE.md tells sessions not to reach for.

Candidate repairs

Either would close it; the first is smaller.

  • Let plugin_add accept a package id. de.h-dv.xaml resolved against the store, refusing with the candidate list when the id is ambiguous across versions — the same shape as bare plugin add refusing when several .cip files sit under <root>/.code-index/plugins/. The operator still answers the confirmation; only the identifier becomes nameable.
  • Put the store inventory in plugin_activation. One row per installed package — digest, id, version, languages, requested capabilities, and whether this project has approved it — which is the installed / requested APPROVED|NOT APPROVED pairing plugin status already computes. This also answers "what could I enable here?", which the id-based repair does not.

The second interacts with #71 (startup/tool-schema payload cap) and #73 (project_overview aggregates), so the inventory should be a bounded list with its own absent/empty distinction rather than an unbounded one — an EMPTY inventory is a measurement ("the store holds nothing"), and an ABSENT one means this daemon did not report, never zero.

Acceptance

An agent with MCP tools and no shell, in a project that has approved nothing, can:

  1. learn that de.h-dv.xaml 0.1.0 is installed in the store and not approved here;
  2. call plugin_add with an identifier it obtained from step 1;
  3. have the operator answer the elicited confirmation and see the grant written.

Step 1 is what is missing today.

Notes

  • Adjacent in shape to #99 (a tool reporting a structural zero with no disclosure, while a sibling tool discloses the same fact fully) — same asymmetry between two front ends over one fact.
  • Separately, _prdoc/guides/80-operator-recovery.md §0.1 is now stale and should be corrected in passing: it states the published package is unsigned and produces signature_missing ("MEASURED 2026-09-02, by reading the release itself: v0.24.0 carries nine assets … There is no de.h-dv.xaml-0.1.0.cips"). The release workflow has since gained its signing step, and v0.26.1 publishes de.h-dv.xaml-0.1.0.cips (112 bytes) plus code-index-publisher.pub. §1.2's four-code repair table is still correct; the §0.1 preamble now describes a fixed defect as current, which sends an operator to generate their own signing key for no reason.
## The gap `plugin_add` is the only plugin tool on the MCP surface. It requires **exactly one of** `digest` (a package already in the store) or `file` (a path to a signed `.cip`). Nothing on that surface can produce either value. An agent session with the MCP tools and no shell therefore **cannot enable a package that is sitting installed and approved-elsewhere in the machine-wide store**, even though `plugin_add` exists precisely to do that. The tool that acts has no companion tool that discovers. ## Measured, 2026-09-04, v0.26.1 (4555887) On this machine the XAML reference package is installed in the user-scoped store and enabled for `E:\code\rust\code-index`: ``` package_store C:\Users\dhoyer\.code-index\packages installed sha256:584fe7183b234e7133fa7d257cc8a8d687f38bfb43d235cfda657ba13b1186dd de.h-dv.xaml 0.1.0 languages=de.h-dv.xaml/xaml requests=bridge_source ``` The store holds the bytes machine-wide; the approval record is per project key, and only one record exists. Enabling it for a second project is a pure grant — no download, no re-install. The operator asked how to have an agent do that in each project, and the honest answer was "it cannot, from MCP alone." **What the MCP surface does report, and why it does not close this.** `project_overview.plugin_activation` carries `active_activation_digest` and `pending_activation_digest` — the *activation* identity of the generation this project is serving, not a package install identity, and in a project that has enabled nothing there is no active generation to name. The block already carries `packages_requested_not_installed`, `package_duplicate_ids` and `package_set_consulted`, so the store is demonstrably in reach at the point the block is built; the inventory simply is not in it. **What the CLI has and MCP does not.** `plugin status` is read-only (`cmd_status`: "READ ONLY, so no disclosure and no write") and its `installed` lines come from `store.installed()` — the machine-wide store, independent of this project's approval record. That is the discovery answer, and it is reachable only through a shell. ## Why this is a product defect and not a workaround The two available workarounds are both wrong in the way this repository's own guides call out: 1. **Hardcode the digest** into a `CLAUDE.md` or a prompt. It works until the package version moves, at which point every copy of the constant becomes a `digest_mismatch` refusal — and a stale pin looks like a stale download. 2. **Shell out to `plugin status`.** Correct today, but it makes the shell a hard dependency for a capability the MCP surface advertises, and it is exactly the shell fallback `CLAUDE.md` tells sessions not to reach for. ## Candidate repairs Either would close it; the first is smaller. * **Let `plugin_add` accept a package id.** `de.h-dv.xaml` resolved against the store, refusing with the candidate list when the id is ambiguous across versions — the same shape as bare `plugin add` refusing when several `.cip` files sit under `<root>/.code-index/plugins/`. The operator still answers the confirmation; only the identifier becomes nameable. * **Put the store inventory in `plugin_activation`.** One row per installed package — digest, id, version, languages, requested capabilities, and whether *this* project has approved it — which is the `installed` / `requested APPROVED|NOT APPROVED` pairing `plugin status` already computes. This also answers "what could I enable here?", which the id-based repair does not. The second interacts with #71 (startup/tool-schema payload cap) and #73 (`project_overview` aggregates), so the inventory should be a bounded list with its own absent/empty distinction rather than an unbounded one — an EMPTY inventory is a measurement ("the store holds nothing"), and an ABSENT one means this daemon did not report, never zero. ## Acceptance An agent with MCP tools and no shell, in a project that has approved nothing, can: 1. learn that `de.h-dv.xaml 0.1.0` is installed in the store and not approved here; 2. call `plugin_add` with an identifier it obtained from step 1; 3. have the operator answer the elicited confirmation and see the grant written. Step 1 is what is missing today. ## Notes * Adjacent in shape to #99 (a tool reporting a structural zero with no disclosure, while a sibling tool discloses the same fact fully) — same asymmetry between two front ends over one fact. * Separately, `_prdoc/guides/80-operator-recovery.md` §0.1 is now stale and should be corrected in passing: it states the published package is unsigned and produces `signature_missing` ("MEASURED 2026-09-02, by reading the release itself: v0.24.0 carries nine assets … **There is no `de.h-dv.xaml-0.1.0.cips`**"). The release workflow has since gained its signing step, and v0.26.1 publishes `de.h-dv.xaml-0.1.0.cips` (112 bytes) plus `code-index-publisher.pub`. §1.2's four-code repair table is still correct; the §0.1 preamble now describes a fixed defect as current, which sends an operator to generate their own signing key for no reason.
Author
Member

Fixed — with the inventory repair, but split, because the rows measurably do not fit where this issue put them.

The repair chosen, and why

Repair 2 (the inventory), not repair 1 (the package id). This issue called repair 1 smaller, and it is — but it does not satisfy acceptance step 1. An agent given an id-accepting plugin_add still cannot learn what is installed; it can only act on a name it already knew. The inventory yields the digest, which plugin_add already accepts, so steps 2 and 3 need no change at all.

The measurement that changed the design

This issue anticipated the interaction with #71 and #73 and asked for a bounded list. It turns out bounded is not enough — the rows do not fit on project_overview at any length. Measured on the daemon leg by suppressing the block and re-running:

baseline with rows with the split
saturated project_overview (ceiling 4300) 4275 4318 — 18 over 4293
agent_task_plugin_bench plugin-wpf (ceiling 7425.6) 7332 7557 — 131 over 7375

225 wire tokens per call on a project with one installed package, against 25 and 93 tokens of headroom. Two independent gates said no, with numbers.

So it is split:

  • the project_overview tool carries {availability, installed_total} plus an inventory URI when there is something to point at — 12 wire tokens of delta;
  • the rows go on code-index://project/overview and code-index://stats — unratcheted, pulled once, and now graded by #107's resource grader, which landed alongside this.

The store path was dropped from the tool block entirely: it is machine-dependent, and a ratcheted constant payload must not carry a machine-dependent value. plugin_add's invalid_arguments hint points at the block, which costs nothing on the success path.

Reuses cmd_status's pairing through the same two primitives — Store::installed() and ApprovalRecord::grant_for — rather than a second definition, as this issue asked. PACKAGE_STORE_INVENTORY_CAP = 20, registered in bounding_site_registry.rs as installed_total + installed_truncated.

Four states, never collapsed

state rendering
absent view unavailable, no counts
Unavailable(why) unavailable + refusal
installed_total: 0 the measurement — installed absent, not []
rows the inventory

The third is the one this issue called out: an EMPTY inventory means the store holds nothing; an ABSENT one means this daemon did not report. They render differently.

Mutations, all RUN

  • take(0) on the rows → RED, code-index://project/overview must carry 'installed'
  • suppress the pointer → RED, a non-zero count must name where the rows are: {"availability":"reported","installed_total":1}
  • constant approved_here: false → RED, the pairing must MOVE

Plus an in-file unit test for all four renderings.

_prdoc/guides/80-operator-recovery.md §0.1 corrected

Verified against the real release before editing, per this issue's note. v0.26.1 publishes 13 assets including de.h-dv.xaml-0.1.0.cips (112 bytes) and code-index-publisher.pub, and release.yml both signs and grades the signature_invalid / signature_missing refusals against the shipped binary. The stale paragraph is replaced, not deleted, and says why — so the next reader can tell a corrected claim from one that was never made.

Two method findings from the lane, both worth keeping

  • A budget subtraction must be in the units of the thing it is subtracted from. The first delta measured the parsed block while the ceilings count the wire line, where the payload is a JSON string with every quote escaped. It under-counted by ~50 tokens and reported #86's ratchet 43 over when it was 8 under. The substring-search alternative is also unsound — a re-serialisation differing by key order finds nothing and silently reports zero — and that is now written into the test.
  • assert_ne! between two whole blocks is weaker than it looks. The collapse mutation survived the inequality pair, because the collapsed rendering still differed by an incidental store field. What caught it was the arm naming the fields an unavailable exit may not carry. Both are kept, and the note records which one actually fired.

One constraint this leaves behind

project_overview is at 4293 of 4300 on the daemon leg. The next disclosure added there will not fit. That is a hard fact for whoever picks up #111 (the payload category split) or #73 — noted there.

## Fixed — with the inventory repair, but **split**, because the rows measurably do not fit where this issue put them. ### The repair chosen, and why **Repair 2 (the inventory), not repair 1 (the package id).** This issue called repair 1 smaller, and it is — but it does not satisfy acceptance step 1. An agent given an id-accepting `plugin_add` still cannot *learn* what is installed; it can only act on a name it already knew. The inventory yields the digest, which `plugin_add` already accepts, so steps 2 and 3 need no change at all. ### The measurement that changed the design This issue anticipated the interaction with #71 and #73 and asked for a bounded list. It turns out bounded is not enough — the rows do not fit on `project_overview` at any length. Measured on the daemon leg by suppressing the block and re-running: | | baseline | with rows | with the split | |---|---|---|---| | saturated `project_overview` (ceiling 4300) | 4275 | **4318 — 18 over** | 4293 | | `agent_task_plugin_bench` plugin-wpf (ceiling 7425.6) | 7332 | **7557 — 131 over** | 7375 | **225 wire tokens per call on a project with one installed package**, against 25 and 93 tokens of headroom. Two independent gates said no, with numbers. So it is split: - **the `project_overview` tool** carries `{availability, installed_total}` plus an `inventory` URI when there is something to point at — **12 wire tokens of delta**; - **the rows** go on `code-index://project/overview` and `code-index://stats` — unratcheted, pulled once, and now graded by #107's resource grader, which landed alongside this. The store **path** was dropped from the tool block entirely: it is machine-dependent, and a ratcheted constant payload must not carry a machine-dependent value. `plugin_add`'s `invalid_arguments` hint points at the block, which costs nothing on the success path. Reuses `cmd_status`'s pairing through the same two primitives — `Store::installed()` and `ApprovalRecord::grant_for` — rather than a second definition, as this issue asked. `PACKAGE_STORE_INVENTORY_CAP = 20`, registered in `bounding_site_registry.rs` as `installed_total + installed_truncated`. ### Four states, never collapsed | state | rendering | |---|---| | absent view | `unavailable`, no counts | | `Unavailable(why)` | `unavailable` + `refusal` | | `installed_total: 0` | **the measurement** — `installed` **absent**, not `[]` | | rows | the inventory | The third is the one this issue called out: an EMPTY inventory means the store holds nothing; an ABSENT one means this daemon did not report. They render differently. ### Mutations, all RUN - `take(0)` on the rows → RED, `code-index://project/overview must carry 'installed'` - suppress the pointer → RED, `a non-zero count must name where the rows are: {"availability":"reported","installed_total":1}` - constant `approved_here: false` → RED, `the pairing must MOVE` Plus an in-file unit test for all four renderings. ### `_prdoc/guides/80-operator-recovery.md` §0.1 corrected Verified against the **real release** before editing, per this issue's note. v0.26.1 publishes 13 assets including `de.h-dv.xaml-0.1.0.cips` (112 bytes) and `code-index-publisher.pub`, and `release.yml` both signs and grades the `signature_invalid` / `signature_missing` refusals against the shipped binary. The stale paragraph is **replaced, not deleted**, and says why — so the next reader can tell a corrected claim from one that was never made. ### Two method findings from the lane, both worth keeping - **A budget subtraction must be in the units of the thing it is subtracted from.** The first delta measured the *parsed* block while the ceilings count the *wire line*, where the payload is a JSON string with every quote escaped. It under-counted by ~50 tokens and reported #86's ratchet **43 over when it was 8 under**. The substring-search alternative is also unsound — a re-serialisation differing by key order finds nothing and silently reports zero — and that is now written into the test. - **`assert_ne!` between two whole blocks is weaker than it looks.** The collapse mutation survived the inequality pair, because the collapsed rendering still differed by an incidental `store` field. What caught it was the arm naming the fields an unavailable exit may not carry. Both are kept, and the note records which one actually fired. ### One constraint this leaves behind **`project_overview` is at 4293 of 4300 on the daemon leg.** The next disclosure added there will not fit. That is a hard fact for whoever picks up #111 (the payload category split) or #73 — noted there.
Author
Member

Closing as already fixed — this describes a gap that 1aa6514 (2026-09-04) had already closed before the issue was written.

Verified in this tree, not taken on report:

  • PackageStoreInventory is a struct at crates/mcp-server/src/server.rs:3555 with 4 resolved references.
  • git merge-base --is-ancestor 1aa6514 HEAD → yes, so it is in the master line this issue was filed against.
  • The fix is effectively candidate repair (2) from the issue body: the store inventory rides on the MCP payload, so the digest plugin_add wants is nameable from the MCP surface. server.rs:3681 spells the follow-through out for the caller: "and no re-install: call plugin_add with that digest and answer the confirmation."

Cause of the bad report, which is the part worth keeping. The measurements in the issue are correct about the binary that produced them and stale about the tree they were checked against. The session read HEAD (7b3fc7c) but dogfooded code-index 0.26.1 (4555887) installed at c:\tools, and 1aa6514 landed after v0.26.1 was cut. Every "the MCP surface cannot produce this" statement was true of the running binary and false of the source beside it.

This is the same skew that has bitten here before, and the guard against it is cheap: run code-index doctor (or compare --version against git log) before explaining field behaviour from HEAD, and reinstall from master before a dogfooding pass. Not doing that manufactured a duplicate issue against shipped work.

What does not survive the closure, so nothing is silently dropped:

  • The stale _prdoc/guides/80-operator-recovery.md §0.1 note in the issue's Notes section is likewise already handled — lines 85-93 now carry an explicit "An earlier edition of this section said the published package was unsigned … That was true of v0.24.0 and is not true now", kept rather than deleted precisely because an operator who followed it generated a signing key they did not need.
  • The acceptance criterion in the issue (an agent with MCP tools and no shell can learn what is installed, then call plugin_add with an identifier it obtained itself) is worth keeping as a gate if one does not already exist against the shipped payload. If it is not covered, that belongs in a fresh issue scoped to the gate rather than reopening this one.
Closing as already fixed — this describes a gap that `1aa6514` (2026-09-04) had already closed before the issue was written. **Verified in this tree, not taken on report:** - `PackageStoreInventory` is a struct at `crates/mcp-server/src/server.rs:3555` with 4 resolved references. - `git merge-base --is-ancestor 1aa6514 HEAD` → yes, so it is in the master line this issue was filed against. - The fix is effectively candidate repair (2) from the issue body: the store inventory rides on the MCP payload, so the digest `plugin_add` wants is nameable from the MCP surface. `server.rs:3681` spells the follow-through out for the caller: *"and no re-install: call `plugin_add` with that `digest` and answer the confirmation."* **Cause of the bad report, which is the part worth keeping.** The measurements in the issue are correct about the binary that produced them and stale about the tree they were checked against. The session read HEAD (`7b3fc7c`) but dogfooded `code-index 0.26.1 (4555887)` installed at `c:\tools`, and `1aa6514` landed *after* v0.26.1 was cut. Every "the MCP surface cannot produce this" statement was true of the running binary and false of the source beside it. This is the same skew that has bitten here before, and the guard against it is cheap: run `code-index doctor` (or compare `--version` against `git log`) before explaining field behaviour from HEAD, and reinstall from master before a dogfooding pass. Not doing that manufactured a duplicate issue against shipped work. **What does not survive the closure, so nothing is silently dropped:** - The stale `_prdoc/guides/80-operator-recovery.md` §0.1 note in the issue's Notes section is likewise already handled — lines 85-93 now carry an explicit "An earlier edition of this section said the published package was unsigned … That was true of v0.24.0 and is not true now", kept rather than deleted precisely because an operator who followed it generated a signing key they did not need. - The acceptance criterion in the issue (an agent with MCP tools and no shell can learn what is installed, then call `plugin_add` with an identifier it obtained itself) is worth keeping as a gate if one does not already exist against the shipped payload. If it is not covered, that belongs in a fresh issue scoped to the gate rather than reopening this one.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#100
No description provided.