Skip to content

MCP 2026-07-28: stateless serve (Decision 1), ratify elicitation (Decision 5), Decision 4 killed - #312

Merged
mavaali merged 3 commits into
mainfrom
claude/daftari-mcp-spec-update-vesd6v
Jul 28, 2026
Merged

MCP 2026-07-28: stateless serve (Decision 1), ratify elicitation (Decision 5), Decision 4 killed#312
mavaali merged 3 commits into
mainfrom
claude/daftari-mcp-spec-update-vesd6v

Conversation

@mavaali

@mavaali mavaali commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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:

  • Decision 1 — daftari serve goes stateless. Migrated @modelcontextprotocol/sdk ^1.29 → @modelcontextprotocol/server 2.0 + @modelcontextprotocol/node 2.0. The session table, isInitializeRequest gate, 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-time matchToken, 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 via serveStdio, 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.
  • Decision 5 — vault_ratify speaks form-mode elicitation. Called without a decision, the tool returns an input_required form (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.
  • Decision 4 — kill condition fired, recorded. 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 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/eval remain 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-era initialize refused 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 test pass 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.
  • New behavior has tests (tests mirror src/; every tool gets a test file)

Invariants

  • Frontmatter stays the only metadata layer; the SQLite index stays derived/ephemeral
  • Curation stays advisory (lint reports, tension logs — no auto-fix/resolve): task-run passes did not ship at all; ratification still requires the human's explicit decision, now with reject preselected
  • Edge/tension visibility: omission over redaction, no existence leak, coarsened remainders — resources' single not-found shape survives the v2 migration (ResourceNotFoundError with byte-identical messages for missing and forbidden)
  • Court/docket code takes no access context; no court surface exposed via MCP
  • Error handling stays Result<T, Error>; no classes; no throws from tool handlers — the elicitation bridge lives in server.ts; tool handlers are untouched

🤖 Generated with Claude Code

https://claude.ai/code/session_01GwqXWgWbqZrYeMfrhFn3e8


Generated by Claude Code

claude added 3 commits July 28, 2026 21:18
… (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
@mavaali
mavaali marked this pull request as ready for review July 28, 2026 22:38
@mavaali
mavaali merged commit 7fbce78 into main Jul 28, 2026
16 checks passed
Comment on lines +243 to +272
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",
),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/serve/index.ts
// (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) ?? "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review summary

Reviewed the MCP 2026-07-28 stateless-serve migration, vault_ratify elicitation, and the Decision-4 kill-condition writeup against the invariants in CLAUDE.md (frontmatter-only metadata, ephemeral SQLite index, git as version layer, advisory-only curation, existence-disclosure rules, Result<T, Error>/no-throw/no-classes).

No invariant violations found:

  • describeRatifyElicitation stays in the Result<T, Error> tool layer; only the transport code in server.ts throws (ResourceNotFoundError), consistent with the existing pre-PR pattern for resource reads.
  • Ratification is still gated on an explicit human decision (default-reject form); nothing auto-applies.
  • The per-process HMAC key for the request-state codec is correctly module-scoped in server.ts (not re-created per createServer() call), which is what makes it work across the stateless per-request createServer calls in daftari serve — good catch by the author, not a bug.
  • Court/docket, storage-backend, and RBAC boundaries are untouched.
  • New behavior is covered by test/ratify-elicitation.test.ts (gates-before-form, decline-leaves-pending, direct-decision bypass) plus updated test/serve/* for the stateless wire contract.

Two left inline, both minor:

  1. describeRatifyElicitation doesn't validate principal the way vaultRatify does, despite the comment claiming parity — a decision-less call missing principal shows the human a form before failing on resubmit instead of erroring upfront.
  2. bearerFrom(req) parses the Authorization header twice per authenticated request in src/serve/index.ts (once in authenticate(), once when building the pass-through req.auth).

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.

github-actions Bot added a commit that referenced this pull request Jul 28, 2026
… 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>
mavaali pushed a commit that referenced this pull request Jul 28, 2026
…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
@claude claude Bot mentioned this pull request Jul 29, 2026
@mavaali mavaali mentioned this pull request Jul 30, 2026
3 tasks
mavaali added a commit that referenced this pull request Aug 11, 2026
…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>
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.

2 participants