feat: add private stateless elicitation runtime - #378
Conversation
899531d to
8711e39
Compare
Coverage Report for CI Build 33064208783Warning No base build found for commit Coverage: 96.469%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
8711e39 to
9b3a690
Compare
Adds the private terminal output infrastructure the elicitation runtime composes with: the advertised output union (business output plus distinct `declined` and `cancelled` variants), and the two result composers. Terminal copy is a parameter, never a template. Business meaning and the final product text stay with the Supabase cost policy. Recovery results deliberately carry no `structuredContent`: recovery is not one of the advertised output variants, so structured content there would put off-schema data on the wire. Plan boundary B5, landed first rather than fifth. The runtime's `ToolPolicy.outputSchema` hook needs the output union from its first line, so the terminal module is a dependency of the runtime commit rather than a follow-up to it. Every plan boundary keeps its own commit; only the order changed.
Adds the mode-less capability resolver. It reads the SDK-owned era and client
capabilities normalized by the tool-policy foundation, and combines them with
the serving-path and opt-out facts the entry point injects. Hosted URL parsing
and route selection stay outside the package.
A mode-less `elicitation: {}` and an explicit `elicitation.form` are both form
capable on a supported modern serving path. URL-only and absent declarations
are not. The legacy era has no multi-round-trip leg to deliver a form on, so
classic hosted and deprecated stdio stay incapable however they declare
themselves.
The resolver takes only `era` and `clientCapabilities`, so a client name or
version cannot reach a branch. There is no compatibility table to drift.
Plan boundary B4, landed second rather than fourth: the runtime commit depends
on this resolver rather than the other way around.
Adds the stateless continuation the elicitation runtime hands the client:
`{ v, policyVersion, policy, tool, argsDigest, proposal, jti, iat, exp }`
readable under an HMAC, bound to the authenticated actor and the originating
MCP method, and capped at 120 seconds. Nothing is stored server side.
Ported by behavior from the reviewed codec in #367 at c5660c9, without the
replay store, the replay adapter, capacity handling, `consume`, and every
other single-use path. Repeating live accepted state is the approved
detection-only posture, so there is nothing to consume.
Verification splits the two failure classes the invariants require.
Malformed input, a failed MAC, a wrong actor, and a wrong MCP method throw,
and the SDK request-state seam answers those as the frozen `-32602` before
any handler runs. An elapsed lifetime resolves instead, carrying the fact
that the value authenticated, so the runtime can answer with recovery text.
That split is also why this package signs its own envelope rather than using
the SDK's `createRequestStateCodec`: the SDK verifier throws on expiry, which
would classify an authenticated expiry as `-32602`.
`jti` never leaves the module. `mint` returns the derived Interaction ID and
`verify` resolves with it, so no caller can read the raw value, log it, or
build a single-use check on it.
Plan boundary B2, landed third rather than second: the runtime commit builds
on this module.
Adds the private runtime that turns an elicitation policy into a pre-execution guard, composed entirely through the public tool-policy interface: `ToolPolicy`, `ToolPolicyDecision`, `ToolRequestContext`, the structured-result normalization, the telemetry allowlist, and the SDK request-state pass-through. The policy contract owns what is proposed, what is asked, and what an answer means. The runtime owns state integrity, lifetime, correlation, capability, and terminal composition. Cost proposals and product copy live in neither. Signed state resolves before anything else, so current capability can neither promote a request that carries no state nor demote one that does. Preparation runs on the first round only: a later round carries the proposal the caller already approved, and a reissue re-signs that same proposal rather than building a new one. Authenticated failures answer in band and create nothing: an elapsed lifetime, a payload-version mismatch, a policy id or version mismatch, a tool mismatch, and changed canonical arguments each return actionable recovery text with its own telemetry reason. Integrity, actor, and method failures stay with the SDK seam as `-32602`. No package entry point exports any of it. The boundary test proves it by value identity across all three supported entry points, and pins the main entry's published surface.
Adds the gate and the contracts for the three runtime invariants that decide how a flow behaves when something changes underneath it. The gate is a kill switch consulted immediately before protected execution. It blocks the attempt, and it neither consumes nor invalidates signed state, so the same continuation succeeds once the gate reopens. Tools without an elicitation policy never reach it and keep running while it is closed. Capability loss between rounds answers with actionable unsupported continuation text. The flow does not silently restart and does not switch to another authority path, proven by preparation running once and business execution never running. Repeating valid accepted state executes again. That is the approved detection-only posture: both attempts carry one Interaction ID and emit their own safe telemetry event, so a duplicate is detectable downstream while nothing here prevents it. There is no consumption, no replay cache, no capacity control, and no same-process ownership.
Closes the private runtime with the terminal contracts the flow reaches through the served stack: a decline and a cancel produce their own distinct non-error variant with explicit text and no business execution, and invalid input asks again on the proposal already signed instead of preparing a second one. Also records in the code that a reissued round opens its own lifetime and Interaction ID, which is the behavior carried over from #367. Nothing here repeats a matrix another layer owns. The output union's parse semantics belong to the terminal module's own tests, the availability matrix to the capability resolver's, and the integrity and binding rows to the continuation state's.
This reverts commit 8a1fda5.
da05e02 to
9e784da
Compare
|
Moving this back to draft while we discuss a simplification: the pinned SDK v2 natively ships the multi-round-trip elicitation mechanism this PR implements (inputRequired + RequestStateCodec), so we're evaluating replacing the private runtime with SDK primitives. Please hold further review until after tomorrow's chat. #377 is unaffected and still ready. |
|
Closing this as superseded by #391, which uses the SDK request-state and MRTR primitives directly. |
What
-32602errors.Why
This supplies the private runtime required by RFC v0.4 without exporting runtime symbols. Cost policy, creation-tool behavior, pricing, hosted URL parsing, and stdio form work remain out of scope.
How to Review
Start with the private contracts
packages/mcp-server-supabase/src/elicitations/policy.tsandpackages/mcp-server-supabase/src/elicitations/runtime.tsTrace state integrity
packages/mcp-server-supabase/src/elicitations/state.tsandpackages/mcp-server-supabase/src/elicitations/codec.tsFollow runtime behavior
runtime.ts,capability.ts,interaction-id.ts, andterminal.tsinpackages/mcp-server-supabase/src/elicitations/Confirm the contracts in tests
state.test.ts,runtime.test.ts,capability.test.ts,terminal.test.ts, andpackage-boundary.test.tsin the same directoryReview questions
Verification
CI=1 mise exec -- pnpm --filter @supabase/mcp-server-supabase test run --project unitCI=1 mise exec -- pnpm --filter @supabase/mcp-utils test runmise exec -- pnpm buildmise exec -- pnpm format:checkAll passed. The diff is limited to 12 files under
packages/mcp-server-supabase/src/elicitations/. Package-boundary tests confirm private symbols remain unavailable from supported exports.