feat: add private stateless elicitation runtime - #378
Draft
barryroodt wants to merge 6 commits into
Draft
Conversation
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 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.
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.
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.