fix(registry,cli): an mcp entry's fields must agree with its transport (TASK-071) - #1778
Merged
Merged
Conversation
…t (TASK-071)
`config.environment` on an installation becomes an instruction on the owner's
machine: agentBinding projects it to that daemon as the seat's declared spec,
and every adapter branches on the shape of the entries in it. The backend write
that stores it (`PATCH /api/registry/pods/:podId/agents/:name`) merged `config`
wholesale with no shape check at all, and the CLI's `validateEnvironmentSpec`
checked only the transport's VALUE, never whether the fields agreed with it.
So `{transport: 'http', url, command}` stored fine and each reader resolved it
differently — pi-mcp-client resolves a hand-built both-fields entry url-first
while its `readServers` filter drops one that is neither. One record, three
adapters, no single answer to what runs.
Vera's rule, applied at both write sites: http/sse require a url and forbid a
command; stdio (or an absent transport, the historical default) requires an
argv array command and forbids a url; an unknown transport is refused rather
than guessed at from whichever field is present.
The backend util and the CLI block are mirrored rather than shared — CJS
backend, published ESM cli — and each names the other. `validateMcpComponent`
is deliberately not reused: it normalizes a plugin manifest component and would
drop the `env` a seat entry carries.
Only the environment a request declares is validated, never the merged stored
value, so a row that already holds a malformed entry stays patchable.
samxu01
force-pushed
the
kai/write-time-mcp-shape
branch
from
September 19, 2026 19:34
719abcf to
21dbdca
Compare
…the same rule (TASK-071) Vera's review of #1778 (70201): the PATCH is not the only backend writer of `config.environment`. `POST /api/registry/install` takes `config` from the body, passes it through `normalizeConfigMap` — a passthrough for a plain object — and stores it as the installation config, unchecked. So an entry whose fields contradict its own transport could still be written at install time. Same validator, same refusal, same code, and placed BEFORE the install call: a 400 that arrives after the side effect is a report rather than a refusal. Corrects the claim in the util's header and in the PATCH route comment, both of which said the PATCH was the only writer. The header now names both and records that the first draft was wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TASK-071. Vera's ruling on #1764 was "change the schema, not the guard"; her follow-up corrected the placement: the write that daemons execute is a backend write, so tightening only the CLI's
validateEnvironmentSpecwould not have validated the field where it is actually written.A correction to my own first draft, found by Vera in review (70201). I wrote that
PATCH /api/registry/pods/:podId/agents/:namewas "the only backend writer ofconfig.environment". It is not.POST /api/registry/installtakesconfigfrom the body (install.ts:111), passes it throughnormalizeConfigMap— a passthrough for a plain object — and stores it as the installation config (:534), unchecked. So an entry that contradicts its own transport could still be written at install time, which is the whole defect one route over. Both writers now call the same validator, and both comments that stated the false count have been corrected to name both.The rule
A transport decides the entry, so the fields have to agree with it:
transporthttp,sseurl(non-empty string)commandstdio, or absentcommand(non-empty argv array)urltransportabsent means stdio deliberately: that is the historical default, and stored rows predate the explicit declaration.What was measured before writing any code
commandis an argv array in every consumer —connectStdioMcpdestructuresconst [cmd, ...args] = command(cli/src/lib/adapters/pi-mcp-client.mjs:90-91) andisStdioServerisArray.isArray(s?.command) && s.command.length > 0(:342), whileurlis always the string form (:344). A stringcommandis currently dropped in silence byreadServers(:355), so the array requirement is part of the agreement rule rather than a separate nicety.transport, andpi-mcp-client's own comment says a hand-built entry carrying both resolves url-first (:80-85). One stored record, three adapters, no single answer to "what runs?" — which is why the write-time check has to be the transport rule and not an inference from which field is present.validateMcpComponent(backend/utils/pluginManifestParser.ts:777): that validates a plugin manifest and normalizes, dropping every field its shape does not carry. A seat environment entry carriesenv(the default declaration puts a token reference there), so routing this path through it would trade a shape bug for a data-loss bug.Two write sites, one rule
backend/utils/environmentSpecValidation.ts(new) — called from both backend writers: the agent-config PATCH (before any write) and the install handler (beforeAgentInstallation.install), each returning a typed400 { code: 'invalid_environment_spec', fields: [{ field, message }] }with every offending entry reported, not the first. In the install handler the check sits immediately afterinstallConfigis built, because a 400 that arrives after the side effect is a report rather than a refusal.cli/src/lib/environment.js— the same block, for the other write path, an operator-authored--environment <file>.They are mirrored, not shared: this is the CJS backend and that is a published ESM package with its own dependency closure, so a runtime import is not available. The wording is kept parallel, each names the other in a comment, and both test files pin the same cases.
The reviewer's gate
That is the first test in
backend/__tests__/unit/routes/registry.environment-mcp-shape.test.js, which also assertssavewas never called; the install twins are inregistry.install-environment-mcp-shape.test.js, where the second test assertsAgentInstallation.installwas never called.The deliberate limit, pinned by a test
Only the environment this request declares is validated, never the merged stored value. Otherwise a row that already holds a malformed entry becomes unpatchable for its other fields, and refusing old records is not what this rule is for.
leaves a row that already holds a malformed entry patchable for its other fieldspins exactly that; mutation M7 (validate the stored value too) reddens it.Evidence
backend/__tests__/unit/routes= 126 suites / 923 tests green; full CLI suite = 40 suites / 643 tests green.command, stops requiringurl; stdio stops forbiddingurl; a string accepted ascommand; the early return on an unknown transport dropped; the route stops calling the validator; the stored value validated too; two CLI equivalents). Two on the second writer: M10 install stops calling the validator (both install tests red), M11 the install check runs after the side effect (only the ordering test red). M13/M14 witness the control, one half each — M13 makes the validator refuse any mcp array, M14 makes the install check refuse wheneverenvironmentis present at all — because an over-refusal is as much a defect as a missing refusal, and a control nothing can redden is decoration. Each applied alone, source restored byte-identical after each.npm run lint:ts0 errors; the new backend helper adds no warnings (the twomax-lenwarnings ininstall.tsare on lines unchanged from main);eslint srcincliexit 0.Version
cli0.1.60 —cli/srcmoved so the guard requires a bump; 0.1.58 is #1777's (merged asbd801882) and 0.1.59 is the TASK-052 baseline's, per connector-ops in the pod.