MCP 2026-07-28: stateless serve (Decision 1), ratify elicitation (Decision 5), Decision 4 killed - #312
Conversation
… (Decision 1) The 2026-07-28 revision removed the initialize handshake and the Mcp-Session-Id header, so serve's session table dies with them: identity is now resolved per request, on every request, against the same config-declared map. The sessions Map, the isInitializeRequest gate, and the session-id-is-not-a-credential re-check (vacuous when every request authenticates itself) are deleted; validateServeStartup, the constant-time matchToken, the OAuth JWKS path, the loopback rebinding guard, and the reject-never-guest rule carry over verbatim. Serve speaks 2026-07-28 only (legacy: reject) — no dual-stacking; lagging clients use stdio, which serves both eras from the same factory via serveStdio. Rides the SDK v2 line published with today's final revision: @modelcontextprotocol/sdk ^1.29 -> @modelcontextprotocol/server 2.0 + @modelcontextprotocol/node 2.0 (the v1 SDK stays as a devDependency so the e2e suite keeps proving a lagging stdio client still works). Single-holder stays the process lock's job, not the transport's (2026-07-20 Decision 4). Spec: docs/superpowers/specs/2026-07-26-mcp-2026-07-28-readiness-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwqXWgWbqZrYeMfrhFn3e8
Called without a decision, vault_ratify now answers with an input_required form — approve/reject, reject preselected — plus opaque request state minted by an HMAC codec: the action id, the vault HEAD at proposal time, and the deciding user, signed so the round-trip is untampered and bound to the same identity. The server remembers nothing between the two requests; the resubmit re-enters the normal dispatch path, which re-validates the action is still pending and conflict-free. A declined form applies nothing and leaves the action pending. The per-process signing key is sound because the process lock guarantees one daftari serves every round of a flow. The gates run before any form: unknown/decided actions error and a role without the ratify grant is denied without ever seeing a form. A direct call with the decision inline keeps working for clients that don't do elicitation, and 2025-era connections ride the SDK's legacy shim. This is the protocol- level expression of the advisory posture: the server proposes, the human disposes. Spec: docs/superpowers/specs/2026-07-26-mcp-2026-07-28-readiness-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwqXWgWbqZrYeMfrhFn3e8
…d, tasks stay CLI-only Checked the final 2026-07-28 revision against the spec's kill conditions: Decisions 1 and 5 survived (implemented in the two preceding commits); Decision 4's condition fired twice over — the final revision moved Tasks to a standalone extension and removed tasks/list outright, and the v2 TypeScript SDK ships the task types as wire vocabulary with no runtime — so the four task tools do not ship and sleep/consolidate/audit/eval remain CLI-only, exactly the outcome the spec priced in. Recorded in the spec, CHANGELOG, and CLAUDE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwqXWgWbqZrYeMfrhFn3e8
| export async function describeRatifyElicitation( | ||
| vaultRoot: string, | ||
| args: Record<string, unknown>, | ||
| access?: AccessContext, | ||
| ): Promise<Result<RatifyElicitationSpec, Error>> { | ||
| if (access && !canRatify(access.role)) { | ||
| return err(new Error(`access denied: role '${access.roleName}' cannot ratify staged actions`)); | ||
| } | ||
| if (access && isProposeOnly(access.role)) { | ||
| return err( | ||
| new Error( | ||
| `access denied: role '${access.roleName}' is propose-only — it cannot ` + | ||
| `ratify staged actions`, | ||
| ), | ||
| ); | ||
| } | ||
| const id = requireString(args, "id", "vault_ratify"); | ||
| if (!id.ok) return id; | ||
| const found = await getStagedActionById(vaultRoot, id.value); | ||
| if (!found.ok) return found; | ||
| const action = found.value; | ||
| if (!action) return err(new Error(`vault_ratify: unknown staged action: ${id.value}`)); | ||
| if (action.status !== "pending") { | ||
| return err( | ||
| new Error( | ||
| `vault_ratify: staged action ${id.value} is '${action.status}', not 'pending' — ` + | ||
| "it cannot be ratified", | ||
| ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Correctness gap: principal isn't validated before the form is shown.
vaultRatify (below, line 319) requires principal via requireString before doing any work. describeRatifyElicitation copies the role/id/status gates from vaultRatify but drops the principal check — even though the doc comment right above (line 235-236) claims "the same gates vaultRatify runs."
Since decision is now optional in the schema (required: ["id", "principal"], line ~710), a client can call vault_ratify with only {id} and no decision. That routes into the elicitation flow: the human sees the form, answers "approve," and only then does the resubmit hit vaultRatify's requireString(args, "principal", ...) and fail with "principal is required" — after the round-trip, not before it, contradicting the stated intent that ungrantable/invalid calls "never see a form."
Worth adding the same requireString(args, "principal", ...) check here (or better, factoring the shared preconditions out of vaultRatify into a helper both call, so this kind of drift can't happen again).
| // (it performs no verification of its own — ours ran above). The bearer | ||
| // is the credential; `_meta` client info is diagnostics, never identity. | ||
| (req as IncomingMessage & { auth?: AuthInfo }).auth = { | ||
| token: bearerFrom(req) ?? "", |
There was a problem hiding this comment.
Minor: bearerFrom(req) is now called twice per authenticated request — once inside authenticate() (line 288, as presented) and again here to populate req.auth.token. Since authenticate() already extracted the token, it could return it alongside the resolved AccessContext (or handle() could reuse a value threaded through) instead of re-parsing the Authorization header a second time.
Review summaryReviewed the MCP 2026-07-28 stateless-serve migration, No invariant violations found:
Two left inline, both minor:
Nothing else risked posting — the rest of the diff (SDK v2 migration mechanics, wire-format string literals, test regex loosening for the new client's error shapes) reads as a faithful, well-documented mechanical migration. |
… landing (#312) origin/main gained one commit past this branch's merge-base: PR #312, "MCP 2026-07-28: stateless serve (Decision 1), ratify elicitation (Decision 5), Decision 4 killed" — landing exactly the work this branch's own MCP-readiness spec had deferred pending an SDK gate that failed as of 2026-07-27. The gate evidently passed by the time #312 shipped (2026-07-28), so main is now the newer, authoritative state for that surface. Reconciled by hand (git merge/fetch/reset/checkout were unavailable in this sandbox — no destructive git ops, no network for npm install): - src/server.ts, src/serve/index.ts, src/index.ts: adopted main's v2 SDK (@modelcontextprotocol/server/node/client) migration — stateless per-request serve, serveStdio dual-era stdio — layered under this branch's own tools/registry.ts extraction, CORE_TOOLS additions (vault_tools, vault_context), and formatSuccessResult presentation helper. - src/tools/staged-actions.ts: added describeRatifyElicitation (Decision 5) alongside this branch's batch `ids` / reason_category / amended_diff / risk_at_decision vaultRatify rewrite. Elicitation is single-`id` only; a batch `ids` call always requires an explicit `decision` (server.ts guards on args.ids === undefined before entering the elicitation round). - docs spec doc, CHANGELOG.md, CLAUDE.md: adopted main's landed-state narrative (superseding this branch's "gate failed, deferred" framing) and merged in this branch's own unrelated Key-decisions bullets. - package.json/package-lock.json: adopted main's SDK dependency swap, kept this branch's `ajv` devDependency (already resolved in main's lockfile at the same version, so no new resolution needed). - test/ratify-elicitation.test.ts (new from main), test/serve/oauth.test.ts, test/serve/serve.test.ts: adopted main's versions verbatim — untouched by this branch. Also discarded a stray pre-existing uncommitted edit to CLAUDE.md (unrelated to this request) that had dropped several of this branch's own Key-decisions bullets. NOT independently verified: npm install (no network egress in this sandbox) and tsc/npm test (both blocked by the sandbox's command allowlist) could not be run here. CI should be treated as the first real build/test signal for this commit. Co-authored-by: Mihir Wagle <40620108+mavaali@users.noreply.github.com>
…ve main Merging main brought in the MCP 2026-07-28 stateless revision (#312), which rewrote src/server.ts and grew src/tools/staged-actions.ts. Rather than fold a silent drift into the merge, every [DATA] citation in the read-path fence design was re-checked line by line. Nine had drifted and are corrected: VaultStatusResult, vaultStatus and scanVaultDocs in read.ts; both checkTierGuard call sites, the sameBody early return, the vaultMerge targetRaw spread and the domain_warnings shape in write.ts; the vault_ratify dispatch range in staged-actions.ts; and the tool-handler-annotated hit fields in hybrid.ts. The index-db drop list gains a line anchor and the current SCHEMA_VERSION. The resolveAccess call-site list gains the serve/sleep line numbers it was asserting without. Two load-bearing claims were re-checked and survive unchanged: the three-channel result bridge the design depends on came through the v2-SDK migration intact, and vault_read still declares no `summarize`, so one change still covers both its channels. One substantive gap. Decision 5's surface list predates the ratify elicitation prompt, which #312 added. describeRatifyElicitation interpolates the staged action's `rationale` verbatim into the message src/server.ts hands the client as an inputRequired form. `rationale` is required caller-supplied free text on vault_stage_action, and a propose-only role can stage — so the least-trusted writer in the RBAC model authors the sentence a human reads at the moment of approving a write. It ships no document body, so it does not breach the literal rule, but leaving it off the list would have shipped exactly the failure mode the section names: a channel carrying foreign text unlabelled. The interpolated span is fenced with fenceSpan, not the whole message; the surrounding prose is daftari's own and fencing the container would mislabel it. Also records that #309 fixed the valid_from SCHEMA_VERSION break, and why CI structurally could not have caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFCxbrpCHmV1uEeCXNwsiY
…nts (#366) (#370) Amends the #312 Decision 1 strict-reject posture with a temporary, opt-in escape hatch: the v2 SDK's stateless legacy fallback answers 2025-era traffic from the same factory — same process, per-request auth/RBAC unchanged, no session table. Default stays legacy: reject. Removal criterion lives in #366. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Implements the pending decisions of the MCP 2026-07-28 readiness spec (
docs/superpowers/specs/2026-07-26-mcp-2026-07-28-readiness-design.md), checked against the final revision published today and the v2 SDK line that shipped with it. Decisions 2/3/6 already landed in #302; this PR covers the rest:daftari servegoes stateless. Migrated@modelcontextprotocol/sdk^1.29 →@modelcontextprotocol/server2.0 +@modelcontextprotocol/node2.0. The session table,isInitializeRequestgate, and session-id-identity re-check are deleted; identity is resolved from the bearer on every request against the same config-declared map (validateServeStartup, constant-timematchToken, OAuth JWKS path, loopback rebinding guard, and reject-never-guest all carry over verbatim). Serve speaks 2026-07-28 only (legacy: "reject"); stdio serves both eras from one factory viaserveStdio, so lagging clients use stdio — the e2e suite drives the built artifact with the v1 SDK client (kept as a devDependency) to prove that promise. Single-holder stays the process lock's job.vault_ratifyspeaks form-mode elicitation. Called without adecision, the tool returns aninput_requiredform (approve/reject, reject preselected) plus opaque request state minted by the SDK's HMAC codec — action id, vault HEAD at proposal time, and the deciding user, verified and identity-bound on resubmit. The server remembers nothing between rounds; the resubmit re-enters the normal dispatch, which re-validates pending/conflict-free. Gates (ratify grant, propose-only, unknown/decided action) run before any form is shown. Declined forms apply nothing; inline-decision calls never elicit.tasks/listoutright, and the v2 TypeScript SDK ships the task types as "wire vocabulary with no SDK runtime" (no extension runtime package exists on npm). Per the spec's own kill condition, the four task tools do not ship;sleep/consolidate/audit/evalremain CLI-only. Outcomes for every decision are recorded in the spec, CHANGELOG, and CLAUDE.md.Test plan
test/serve/serve.test.ts— rewritten for the stateless contract: per-request 401/403, RBAC vantage per bearer, a 2025-erainitializerefused with no session id ever issued, rebinding guard unchanged (driven by the v2 client pinned to 2026-07-28).test/serve/oauth.test.ts— JWKS/subject-mapping suite migrated to the v2 client; same assertions.test/ratify-elicitation.test.ts— new: full wire round-trip (form → accept/decline → signed-state echo → dispatch), form shape (reject default), pending-on-decline, inline-decision bypass, gates-before-form.test/e2e/server.e2e.test.ts— unchanged and passing on the v1 SDK client, proving lagging stdio clients still work against the v2 server.npm run lint,npm run build,npm testpass locally — full suite: 2411 passed; the 30 failures in 9 files (search/embedding + root-permission suites) are byte-identical on the base commit in this sandbox (blocked model downloads), verified via a baseline worktree run.src/; every tool gets a test file)Invariants
ResourceNotFoundErrorwith byte-identical messages for missing and forbidden)Result<T, Error>; no classes; no throws from tool handlers — the elicitation bridge lives inserver.ts; tool handlers are untouched🤖 Generated with Claude Code
https://claude.ai/code/session_01GwqXWgWbqZrYeMfrhFn3e8
Generated by Claude Code