Repository navigation
fix(control-plane-sdk): make the MCP path enforce required idempotency identically to the SDK (SPL-266) - #260
Conversation
…y identically to the SDK (SPL-266) `flags_delete` and `flag_variants_delete` declare `idempotency: "required"` but have no request body, so their derived MCP tool schemas carried no `idempotency_key` field and were `additionalProperties: false`. The adapter reads the key out of the input record, so it silently omitted the header and both tools were an unconditional 400 with no remedy an agent could reach. - `deriveInputSchema` adds a required `idempotency_key` to any `required` route whose body does not already carry one, so the remedy is discoverable. - The MCP adapter lifts the key through the same `withIdempotencyHeader` helper the typed clients use: one rule, one failure behaviour. - `FlagsClient.delete` / `deleteVariant` now take a required `ControlPlaneIdempotentOperationOptions`, so a missing key is a compile error. The runtime throw stays as the backstop for untyped callers. - New `mcp-idempotency-header.contract.test.ts` is exhaustive by construction over `routeRegistry`: a route flipped to `required` cannot ship without an MCP probe proving the header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. To trigger a review, include ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…ror (SPL-266) The client-side refusal added for SPL-266 reached agents as JSON-RPC -32603 "Internal error" with the remedy buried in error.data.message as prose: a downgrade from the structured ErrorResponse the Worker returns for the same rule. The handler now maps the shared IdempotencyKeyRequiredError to a tool result with isError: true carrying VALIDATION_ERROR, and keeps the generic Internal error for genuinely unexpected throws. Also: move the `?.` backstop rationale onto the shared options type instead of repeating it per call site, extract the duplicated request-capture harness the two idempotency contract tests share, and correct the derivation contract doc, which still claimed the key always comes from the body schema and omitted the two DELETEs from the required list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 2 — review feedback addressed in 0a98e79. Blocker (fixed): a missing Fix: Should-fix 1: the Should-fix 2: Should-fix 3: Two incidental fixes the gate forced, both mechanical:
Every package touched by this PR executed rather than replayed: No e2e (no |
`withIdempotencyHeader` rejected only `undefined`, so `idempotency_key: ""` set an empty header, crossed the boundary, and came back as the Worker's VALIDATION_ERROR against `["headers","idempotency-key"]` — a path an MCP caller cannot act on, and a second path vocabulary for the same field in one session. Also corrects three comments and one spec paragraph that claimed the client-side refusal carries "the same shape" as the Worker's. It carries the same code and the same details.issues envelope; the path is surface-local by design, because naming an HTTP header to a JSON-only caller is the impossible remedy ADR-0036 forbids. The doc now states the reason, not just the fact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes SPL-266.
The rule
idempotency: "required"means one thing on every path: a mutation that cannot name its own replay is refused before it leaves, by the caller's own client, in the caller's own vocabulary.Before this PR the SDK threw locally on a missing key while the MCP path silently shipped a request the Worker could only reject — and for the two body-less DELETEs (
flags_delete,flag_variants_delete) the derived tool schema had noidempotency_keyfield at all, so an agent was advertised a call it could not satisfy. That is the impossible remedy ADR-0036 forbids.What changed
packages/contracts/src/mcp-tools.ts): arequiredroute whose contract gives the key no request body getsidempotency_keyinjected into its flat tool schema. Uniform rule: every required route advertises the field, from the body when there is one and by injection when there is not.packages/control-plane-sdk/src/idempotency-header.ts): the MCP adapter now lifts the key through the samewithIdempotencyHeaderthe typed clients use, instead of its own inline check. No parallel helper, no third behaviour.withIdempotencyHeaderthrowsIdempotencyKeyRequiredError, carrying anErrorResponsewith the Worker'sVALIDATION_ERRORcode anddetails.issues[]envelope. A blank or whitespace-only key is refused the same way — it names no replay, and forwarding it would move the refusal across the boundary.apps/mcp-server/src/mcp-handler.ts): that one class maps to a tool result withisError: true. Previously it surfaced as JSON-RPC-32603 "Internal error"with the remedy buried inerror.data.messageas prose. The genericInternal errorbranch is untouched and still covers unexpected throws. The catch body is a namedtoolCallFailurehelper (the inline branch pushedcallToolto cognitive complexity 11).operation-result.ts,flags-client.ts): the two DELETEs takeControlPlaneIdempotentOperationOptions, so a typed caller omitting the key is a compile error. The runtime?.backstop stays for untyped/JS callers, with the reason documented on the shared type rather than per call site.IdempotencyKeyRequiredErroris exported from a real./idempotency-headersubpath. Re-exporting it throughmcp-operation-adapter.tstrippednoBarrelFile, so the subpath was forced, not preferred.apps/mcp-server/vitest.config.unit.tsgained the matching alias plus a note on the prefix-match ordering hazard.docs/spec/contracts/mcp-tool-derivation.md): corrected — the key is no longer always "derived from the route body schema", the two DELETEs are named, and the refusal shape is specified.On "same shape"
The client-side refusal carries the Worker's
codeand itsdetails.issues[]envelope, not its issue path.worker-runtimereads theIdempotency-Keyheader and reports["headers","idempotency-key"]; an MCP caller sends JSON and cannot set a header, so the refusal reports["idempotency_key"]— the field the agent actually controls. The spec paragraph states that split and the reason, so the next client-side check copies it deliberately.Proved by execution
flags_delete/flag_variants_deletefailed "advertises idempotency_key as a required field"; all 10 required routes failed "fails loud when the key is missing" withpromise resolved "Request { method: 'POST', … }" instead of rejecting.error TS2578: Unused '@ts-expect-error' directive.idempotency: "required"ontoflags_list→these routes declare idempotency: "required" but no MCP probe proves the adapter sends the Idempotency-Key header: expected [ 'flags_list' ] to deeply equal []. Reverted.-32603regression, RED before the fix:expected { code: -32603, …(2) } to be undefined, received{ code: -32603, data: { message: "control-plane-sdk: flags_delete requires an idempotency key" }, message: "Internal error" }. GREEN after. A second test pins that a genuinely unexpected throw still yields-32603with its owndata.message; it passed in the RED run too, so it constrains the fix rather than moving with it.expected function to throw an error, but it didn't, andexpected { headers: { "idempotency-key": " " } } to deeply equal {}. GREEN after.pnpm verify:ci(plain, noTURBO_FORCE), exit 0:Tasks: 85 successful, 85 total/Cached: 61 cached, 85 total. Every package this PR touches executed rather than replayed.contractscame back cached in the final run: it is unchanged since the first commit, and its four task hashes are identical to the run where each wascache miss, executing.Reasoned about, not executed
local-e2e-fleetmachine lock held).McpOperationCallOptions.idempotencyKeyis dead code, pre-existing and left alone.code. Same defect class, out of this PR's scope; the handler comment is scoped to the idempotency rule so it does not promise otherwise.