Skip to content

feat: add private stateless elicitation runtime - #378

Draft
barryroodt wants to merge 6 commits into
barryroodt/ai-1091-tool-policy-foundationfrom
barryroodt/ai-1091-stateless-elicitation-runtime
Draft

feat: add private stateless elicitation runtime#378
barryroodt wants to merge 6 commits into
barryroodt/ai-1091-tool-policy-foundationfrom
barryroodt/ai-1091-stateless-elicitation-runtime

Conversation

@barryroodt

Copy link
Copy Markdown
Contributor

Stacked PR: PR B of three for AI-1091, stacked on PR A, which this branch targets.

What

  • Adds signed, client-readable continuation state with a fixed 120-second maximum. It binds the request to its policy, tool, canonical arguments, actor, and MCP method.
  • Returns recovery text for authenticated expiry. Tampered or malformed state, the wrong actor, and the wrong method remain SDK-owned -32602 errors.
  • Gives continuation state precedence over current capability routing and fails protected execution closed. Accepted state can execute again during its lifetime. The same Interaction ID correlates attempts, with no replay prevention or single-use guarantee.
  • Supports modern mode-less and explicit form declarations. Classic and unsupported paths stay outside form mode.
  • Keeps decline and cancel distinct, preserves business structured output, and gives errors explicit text.

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 unit
  • CI=1 mise exec -- pnpm --filter @supabase/mcp-utils test run
  • mise exec -- pnpm build
  • mise exec -- pnpm format:check

All 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant