// TL;DR — the protocol lost the session. The system did not.

[00] Result first

A handle is the reference a server returns so an operation can be found later: think of the number printed on a receipt. That number identifies which record to retrieve; it should not decide who has the right to open it. Obvious, put that way. Then you open production code and find that the receipt opens the till.

Why

The revision makes MCP stateless at the protocol layer, and with the session goes the habit of leaning on it. In exchange, the spec leaves an explicit normative requirement about application handles. We wanted to know whether that requirement holds up when implemented literally.

What we did

Four authenticated principals, a fictitious in-memory job, three key strategies. No network, no AWS account, no real target. The handle is always a UUID v4: only how the record is indexed changes.

What we found

With handle-only lookup, Mallory reads Alice's note and modifies her job. With the concatenated <owner>:<handle> key — the form the spec offers as an example — a structured principal still gets through by moving the delimiter one segment. With the pair encoded as a tuple, both attacks end on scan not found.

The result in one sentence

A UUID makes a handle hard to guess; it does not authorize whoever presents it. Identifier entropy and ownership checks solve two different problems, and the second is not solved by lengthening the first. The suite verifying this distinction — and showing that even the binding can be written badly — passed 13 out of 13 tests.

We are not claiming a vulnerability in MCP: the weakness lives in the implementation, and it is exactly the weakness the spec describes and forbids. The contribution here is twofold — an executable test for a requirement that had none, and a crack in the shape of the key the spec itself suggests. If that is enough for you, stop here: from [01] onwards we go into sources, specification, code and transcripts.

[01] Scope and method

The starting point was a post summarizing the revision as MCP's shift from session-based to stateless. The post was used as a trigger, not as a source — the difference between those two is more or less the whole job. Claims were checked against the specification, changelog, deprecation registry, security considerations, security best practices and the TypeScript SDK v2 guide.

Normative fact

MUST, SHOULD or behavior explicitly stated by the specification.

Observation

Output produced by the lab, with version, command and transcript.

Interpretation

Reasoned architectural consequence, not guaranteed by the protocol.

Limitation

Condition narrowing the validity of the result or preventing generalization.

Collaborative workflow · 50% human / 50% AI

The research question, scope, threat model and criteria are mine, along with the interpretation of the results and responsibility for every conclusion. The rest: a first AI pass verified the sources, implemented the suite and held the QA bar. A second one, with a clean context — in my workflow its job is precisely to dismantle whatever I have just finished writing — went back to the Security Best Practices and observed that the “hardened” variant, the one I had just declared hardened, could be bypassed by moving a delimiter one segment. That is where T13 came from. The most interesting part of this article was found by the review, not by the author: which is why the review exists.

Claim boundary

The technical boundary: what is tested here is the binding of an application handle created by the tool, not the normative requestState field in Multi-Round Tool Requests (MRTR). MCP is a vendor-neutral project under the Linux Foundation's Agentic AI Foundation, and this lab has nothing to say about the protocol itself: it has something to say about how it gets implemented.

[02] What actually changed

Before saying anything about what became more or less secure, it helps to line up what actually changed. Spoiler: almost everything that disappears from the protocol reappears, wearing a different hat, in your application code.

Area 2025-11-25 2026-07-28 Security implication
Session initialize handshake; optional Mcp-Session-Id on Streamable HTTP Independent requests Context must be reconstructed and verified per request.
Negotiation Initial handshake Per-request _meta; server/discover optional for the client Version and capabilities are not identity and do not authorize the caller.
Multi-round state Server-to-client channel input_required with inputRequests and/or requestState State returned by the client must be treated as untrusted input; integrity, binding and expiry are implementation responsibilities.
Broken stream SSE resume/redelivery New request and new ID Ambiguous retries may require application-level idempotency to prevent duplicate effects.
Roots, Sampling, Logging Active Deprecated and present in the revision Deprecation is not removal; support and compatibility depend on the revision and SDK.

On the authorization side the revision tightens up: iss validation on authorization responses, binding credentials to the authorization server that issued them, application_type during DCR. All of it while remaining optional (specification). A protocol can raise the bar as high as it likes: if the bar is optional, someone will still walk underneath it.

The Office — they're the same picture
// “find the difference between session state and application state” — they're the same picture

[03] Stateless does not mean state-free

The revision removes protocol session state. At least four other forms of state remain: identity and authorization, application process data, operation deduplication and event correlation. They were not eliminated: they changed owners, and the new owner is you.

Client
token + request + _meta
MCP handler
new context per request
Tool + datastore
business state and policy

Why an agentic system never needs to guess a handle

On exposure, the spec says one thing: the attacker obtains or guesses the handle. In a classic web app that verb is a real barrier: without enumeration or interception you go nowhere. In an agentic system the right verb is a third one the spec does not name: collect. A value returned by a tool travels through model context, a transcript, a checkpoint, a trace, a retry queue, input delegated to a subagent. None of those components was designed as a security boundary, and all of them can read a string back.

# architectural exposure path — not exercised by T01–T13
tool result
  └─ opaque handle
       ├─ model context / transcript
       ├─ checkpoint / trace
       ├─ retry queue
       └─ subagent or workflow handoff
                ↓
       replay by another principal
                ↓
       handle-only lookup → cross-principal access

These are plausible exposure surfaces, not leaks observed in the tests: the lab does not simulate how the handle changes hands, it assumes it already has. The point is not that every orchestrator exposes these values. It is that, if the backend authorizes through the handle alone, every component able to read and replay it enters the security boundary — including the ones you added last week for debugging. Reducing propagation helps; verifying ownership at the destination is not negotiable.

This is fine — dog in burning room
// the handle in the transcript, the checkpoint, the retry queue and the subagent prompt. everything under control
Thesis

State has not disappeared, it has only moved. And the new address is somewhere nobody had yet placed an authorization check, because until now the session took care of it.

[04] Threat model

ElementIn the lab
AssetFictitious job and private note associated with its owner.
PrincipalsAlice and tenant-a:svc, the owners; Mallory and tenant-a, authenticated but unauthorized for the first two's records.
Entry pointread_scan(handle) / complete_scan(handle)
Trust boundaryToken → verified principal; handle → application record.
WeaknessA lookup that ignores the owner, or that includes it in an ambiguous key.
ImpactCross-principal read or modification: an application-level equivalent of IDOR/BOLA on the handle.
What the spec calls it

This is not a category we invented. Revision 2026-07-28 calls it State Handle Hijacking and forbids it with a MUST NOT: “MCP servers MUST NOT treat possession of a state handle as authentication”. On the same page it recommends a SHOULD: bind the handle server-side to the authenticated user, “for example by keying stored state as <user_id>:<handle>”, with the id derived from the verified token and not supplied by the client (Security Best Practices). The lab implements that SHOULD literally, and then breaks it.

The threat model starts from a declared precondition: the attacker already holds the handle. For why that precondition is realistic rather than a lab convenience, see [03].

[05] Lab: environment and rules

The lab runs entirely in-process: the MCP client and handler talk through a controlled fetch function, with no sockets, DNS or network. create_scan scans nothing — the name is the only aggressive thing about the tool: it creates an in-memory object with the fixed target demo.local, a status and a demonstration note.

OAuth client, client metadata and user are not the same thing

The lab contains three identifiers answering different questions, and confusing them is the most elegant way to compromise yourself. clientInfo describes the software instance declared by the client; clientId identifies the OAuth client; the verified sub claim identifies the principal to which resource policy applies. All principals intentionally share the same clientId: using that value as owner would have merged four distinct users into a single authorization domain, while looking like the right thing to do.

// fixture verifier used by the lab
const verifier = {
  async verifyAccessToken(token) {
    const actor = Object.entries(TOKENS)
      .find(([, knownToken]) => knownToken === token)?.[0];

    if (!actor) {
      throw new OAuthError(OAuthErrorCode.InvalidToken, "unknown token");
    }

    return {
      token,
      clientId: "state-handle-lab", // same for every principal
      scopes: ["mcp"],
      expiresAt: Math.floor(Date.now() / 1000) + 3600, // real time on purpose
      extra: { sub: actor }          // alice, mallory, tenant-a, tenant-a:svc
    };
  }
};

const actor = context.authInfo?.extra?.sub;

extra.sub is an explicit fixture choice, not an identity mechanism normatively imposed by MCP. In production, mapping depends on the verifier and authorization server; the architectural requirement is to derive the principal from verified credentials, never from tool arguments or self-declared metadata. The full code is in the test harness.

The authentication gate runs before the MCP handler

const gate = requireBearerAuth({
  verifier,
  requiredScopes: ["mcp"]
});
const handler = createMcpHandler(factory, { legacy: "reject" });

const auth = await gate(request);
if (auth instanceof Response) {
  response = auth; // missing or unknown token: 401
} else {
  metrics.handlerRequests += 1;
  response = await handler.fetch(request, { authInfo: auth });
}

T02 and T03 verify that a missing or unknown token ends with 401 before the handler and without tool dispatch. This demonstrates the gate's position in the lab, not an end-to-end OAuth flow: issuer, audience and real token acquisition remain out of scope. Most importantly, authenticating the caller does not automatically authorize the individual tool or requested record.

# identities used only inside the owned lab
alice-demo-token         → principal: alice
mallory-demo-token       → principal: mallory
tenant-a-demo-token      → principal: tenant-a
tenant-a-svc-demo-token  → principal: tenant-a:svc   # structured id, used by T13

# simulated tools
create_scan(target="demo.local", note="alice-private")
read_scan(handle)
complete_scan(handle)
# commands run from a clean lockfile install
npm ci
npm test
npm run evidence
13/13 tests passed

Code, lockfile, test matrix and sanitized output are in the reproducible lab. Machine-readable evidence is available as TAP and an environment manifest.

[06] Baseline: am I really speaking MCP 2026?

In the documented migration path for the TypeScript SDK v2, the client must explicitly opt in to the modern revision. Before testing any control, then, the baseline has to prove the negotiated era is modern (migration guide). This looks like pedantry. It is in fact the only way not to publish an article about a revision your own client never negotiated — a class of mistake that is silent, elegant and deeply embarrassing.

# observed and sanitized request — test T01
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: create_scan
Authorization: Bearer [REDACTED]

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "tools/call",
  "params": {
    "name": "create_scan",
    "arguments": { "target": "demo.local", "note": "[NOTE_REDACTED]" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "alice-lab-client",
        "version": "1.0.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

[07] When the implementation turns the handle into a bearer credential

The first variant stores jobs in a process-level shared Map, outside the per-request context. It uses opaque handles generated with randomUUID(), but lookup considers the handle alone. Alice creates a job; Mallory presents the same value returned to Alice. If the server returns the record, knowledge of the handle has effectively become a bearer credential.

Controlled experiment: the key changes, not the entropy

The three variants use the same tokens, the same tools, the same UUID v4 generator and the same inputs. The only variable is the key used to store and retrieve the record. That isolates the ownership check, with no way to blame a weak or predictable identifier — the favourite alibi whenever an IDOR turns up.

// real delta from lab/src/harness.mjs
const nextHandle = handleGenerator ?? (() => randomUUID());

const KEY_STRATEGIES = {
  vulnerable: (owner, handle) => handle,
  naive:      (owner, handle) => `${owner}:${handle}`,
  hardened:   (owner, handle) => JSON.stringify([owner, handle])
};
const keyFor = KEY_STRATEGIES[mode];

const handle = nextHandle();
const scan = {
  handle,
  owner: actor,
  target,
  note,
  status: "pending",
  expiresAt: clock() + ttlMs
};

store.set(keyFor(actor, handle), scan);
const loaded = store.get(keyFor(actor, handle));

T04 turns the threat model into a very simple assertion: Alice creates the record; Mallory uses exactly the handle returned to Alice; the test passes only if Mallory obtains the owner's record and private note. T05 repeats the path for a write.

// executable assertion from test/03-vulnerable.test.mjs
const created = toolJson(await alice.callTool({
  name: "create_scan",
  arguments: { target: "demo.local", note: "alice-private" }
}));

const stolen = toolJson(await mallory.callTool({
  name: "read_scan",
  arguments: { handle: created.handle }
}));

assert.equal(stolen.owner, "alice");
assert.equal(stolen.note, "alice-private");

// observed sequence — tests T04 and T05
Alice   → create_scan()              → [HANDLE_REDACTED]
Mallory → read_scan(handle)           → owner=alice · noteMatched=true
Mallory → complete_scan(handle)       → status=completed
Alice   → read_scan(handle)           → status=completed
Observed result · T04–T05

In the intentionally vulnerable variant, Mallory — authenticated as a distinct principal — read Alice's note and modified her record using the handle returned to Alice. The handle-only lookup protected neither confidentiality nor integrity.

Hackerman — pixel art hacker typing
// the devastating exploit: pasting back a string the server just handed you

[08] Hardened variant: ownership before entropy, and a key that cannot be bent

The fix is not making the handle longer. A UUID lowers the odds of guessing it; it does not stop it from ending up in a chat, a log or a subagent's prompt, where odds no longer matter. The decisive control is binding the record to the authenticated principal and verifying that relationship on every call. But how matters: that is where the spec leaves a door open.

// hardened lookup — conceptual form
const principal = verifiedPrincipal(request);
// the pair as a tuple, not as a concatenated string
const scan = scans.get(JSON.stringify([principal.id, handle]));

if (!scan || scan.expiresAt <= clock()) {
  audit({
    actor: principal.id,
    action: "read_scan",
    handleHash: sha256(handle),
    outcome: "denied",
    traceId
  });
  return {
    content: [{ type: "text", text: "scan not found" }],
    isError: true
  };
}

audit({
  actor: principal.id,
  action: "read_scan",
  handleHash: sha256(handle),
  outcome: "allowed",
  traceId
});
return mcpTextResult(scan);

Denying the attacker without breaking the legitimate path

A negative authorization test is not enough if the control also blocks the owner. T07 verifies both halves: Mallory cannot complete the record; Alice still sees it as pending and can complete it. The fix protects the resource while preserving expected functional behavior.

// executable assertions from T07
const denied = await mallory.callTool({
  name: "complete_scan",
  arguments: { handle: created.handle }
});
const beforeOwnerWrite = toolJson(await alice.callTool({
  name: "read_scan",
  arguments: { handle: created.handle }
}));
const ownerWrite = toolJson(await alice.callTool({
  name: "complete_scan",
  arguments: { handle: created.handle }
}));

assert.equal(denied.isError, true);
assert.equal(beforeOwnerWrite.status, "pending");
assert.equal(ownerWrite.status, "completed");
Observed result · T06–T09

Binding to the principal denied both read and write to Mallory with the same generic error, while Alice retained access and write capability. Also verified: expiry and on-access cleanup without sleep, a trace ID, a SHA-256 handle hash, and absence of the four controlled secrets from the serialized audit.

Deterministic TTL: testing time without waiting for it (and without cheating)

Using sleep would have made the suite slow and potentially flaky. The lab injects a clock function instead: T08 advances time by 1,001 ms, presents the handle again and verifies both denial and on-access removal from the Map. The boundary deserves precision: that clock governs the TTL of the application record, not credential expiry, which stays on the wall clock because the bearer middleware validates it. Pinning the token to the injected clock too would have produced a suite that passes today and fails tomorrow — the kind of test you find broken in CI on a Friday afternoon. This is not periodic garbage collection and does not simulate TTL in a distributed datastore: it demonstrates the implemented application rule, and nothing more.

// deterministic expiry test — T08
let now = Date.UTC(2026, 6, 29, 12, 0, 0);
const lab = createLabHarness({
  mode: "hardened",
  clock: () => now,
  ttlMs: 1_000
});

const created = toolJson(await alice.callTool({
  name: "create_scan",
  arguments: { target: "demo.local", note: "alice-private" }
}));

now += 1_001;
const denied = await alice.callTool({
  name: "read_scan",
  arguments: { handle: created.handle }
});

assert.equal(denied.isError, true);
assert.equal(lab.store.size, 0);
assert.equal(lab.audit.at(-1)?.reason, "expired");

Correlatable audit without turning logs into a second leak

The lab logger uses allowlisted events: actor, action, decision, reason, trace ID and a SHA-256 hash of the handle. The asymmetry is deliberate: towards the caller the error is always the same scan not found, so “does not exist” cannot be told apart from “is not yours”; in the internal audit the reason is explicit, not_found_or_not_owner or expired. Opaque outside, precise inside — otherwise the oracle you closed at the front door walks back in through the logs. T09 serializes the full audit and checks it contains neither the UUID, nor the private note, nor the fixture tokens: a property verified against four controlled secrets, not a universal redaction guarantee. A stable hash remains correlatable and is not equivalent to anonymization.

// implementation and negative disclosure assertion — T09
function hashHandle(handle) {
  return createHash("sha256").update(handle).digest("hex");
}

audit.push({
  actor,
  action,
  outcome,
  ...(reason ? { reason } : {}),
  handleHash: hashHandle(handle),
  traceId
});

const serialized = JSON.stringify(lab.audit);
for (const secret of [
  created.handle,
  "alice-private",
  TOKENS.alice,
  TOKENS.mallory
]) {
  assert.ok(!serialized.includes(secret));
}

In production, for lower-entropy or enumerable identifiers, an HMAC with a separate correlation key reduces offline reconstruction risk. The key must not be embedded in code or logs; the actor identity may itself require pseudonymization. Hashes and HMACs support operational correlation, never authorization.

The key the spec suggests, and why it is not enough

The Security Best Practices document recommends keying state “for example by keying stored state as <user_id>:<handle>”. Implemented literally, in JavaScript that becomes `${owner}:${handle}`, and it is what anyone would write. The problem is that concatenation does not preserve the boundary between the two parts: ("a", "b:c") and ("a:b", "c") produce the same string. If the principal id can contain the delimiter, the boundary is movable by the caller — and the handle arrives as a tool argument, so the caller controls all of it.

Structured principals are not a textbook curiosity: multi-tenant issuers produce them, and so does the canonical iss + sub form used in the AWS sketch in [10]. tenant-a:svc's record with handle H lands under tenant-a:svc:H. The attacker, authenticated as tenant-a, presents handle svc:H: the server concatenates and gets the same key. Ownership was verified. The check simply answered the wrong question.

Aldo, Giovanni e Giacomo — pignolo
// “come on, the delimiter is a detail” — and indeed T13 is forty lines of test
// executable assertions from test/06-key-binding.test.mjs — T13
const VICTIM   = "tenant-a:svc";
const ATTACKER = "tenant-a";

// identical bytes on the wire in both variants; only the server-side key differs
const smuggled = `svc:${created.handle}`;
const attempt  = await attacker.callTool({
  name: "read_scan",
  arguments: { handle: smuggled }
});

// the same smuggled lookup, run against each key strategy
const naiveRun    = await attemptSmuggledLookup(createLabHarness({ mode: "naive" }));
const hardenedRun = await attemptSmuggledLookup(createLabHarness({ mode: "hardened" }));

// naive: `tenant-a` + `svc:H` concatenates onto `tenant-a:svc` + `H`
assert.notEqual(naiveRun.attempt.isError, true);
assert.equal(toolJson(naiveRun.attempt).owner, "tenant-a:svc");
assert.equal(toolJson(naiveRun.attempt).note,  "svc-private");

// hardened: ["tenant-a:svc", H] and ["tenant-a", "svc:H"] stay distinct
assert.equal(hardenedRun.attempt.isError, true);
assert.equal(toolText(hardenedRun.attempt), "scan not found");

The fix is to stop joining the pair and start encoding it: JSON.stringify([owner, handle]), or a length-prefixed key, or two separate attributes in a datastore that keeps them distinct by construction — which is exactly what partition key and sort key do in [10], and one of the rare cases where DynamoDB saves you without asking anything in return. None of these is slower, longer or harder than concatenation.

To be fair: the spec writes “for example”, so it illustrates an idea rather than mandating a format. But an example inside a security best-practices document does not get read as an illustration — it gets copied. And it ends up in production with the same delimiter, in the same place, looking compliant.

Galaxy brain meme
// JSON.stringify([owner, handle]) — same line, zero delimiters left to move
Observed result · T13

With identical bytes sent, the concatenated key returned another principal's record and private note; the tuple key answered scan not found. Both variants check ownership: only one checks it unambiguously.

Server-side handle or self-contained state?

The lab verifies only the first model: an opaque handle points to an authoritative record held by the server. A stateless architecture may also return signed or encrypted self-contained state to the client, but this moves the controls rather than removing them.

ModelWhat travels through the clientAdvantageControls and costs
Server-side Opaque handle Revocation, TTL, one-time use and updates on an authoritative record. Storage, lookup, consistency and cleanup; ownership verified on every access.
Self-contained Protected claims and payload Fewer lookups to reconstruct state; compute is easier to keep stateless. Binding to principal, tool, audience and expiry; MAC/signature, optional AEAD, key rotation and replay control.
Architectural sketch · not executed in the lab
state = seal(
  { sub: principal.id, action: "complete_scan", exp, jti, payload },
  { aad: "mcp-state:v1" }
);

claims = open(state);
assert(claims.sub === principal.id);
assert(claims.action === requestedTool);
assert(claims.exp > now);
await consumeOnce(claims.jti); // for non-repeatable side effects

Signing or encryption can protect integrity and confidentiality, but they do not automatically authorize the caller and do not prevent replay on their own. This is design guidance: it was not executed and is not evidence about the normative MRTR requestState field.

[09] Controls the protocol actually provides

The new revision defines normative errors that stop these inconsistencies before dispatch: -32020 for a header/body mismatch, -32602 for a modern envelope missing required _meta, and -32022 for an unsupported version (specification). The lab observed them with the TypeScript SDK 2.0.0 createMcpHandler (SDK guide). In all three cases the tool counter stayed unchanged: no dispatch. This is the part where the protocol does its job, and does it well — worth saying, since so far I have used it as a backdrop for someone else's problems.

TestManipulationExpected outcomeObserved outcome
Header/bodyMcp-Name: read_scan + body complete_scan400 / -32020; dispatch count unchangedPASS · T10
EnvelopeModern header with missing _meta400 / -32602PASS · T11
VersionHeader and _meta with the same future revision400 / -32022PASS · T12

A pre-dispatch control, shown as a test

T10 snapshots the tool counters, intentionally sends read_scan in the header and complete_scan in the body, then checks HTTP status, request ID, JSON-RPC code and absence of dispatch. The test therefore does more than look for an error: it proves the inconsistent tool was not executed.

// executable assertions from test/05-protocol.test.mjs
const before = { ...lab.dispatch };
const body = modernEnvelope({
  id: "mismatch-1",
  name: "complete_scan",
  arguments: { handle: "scan-x" }
});

const { response, payload } = await post(
  lab,
  body,
  modernHeaders({ name: "read_scan" })
);

assert.equal(response.status, 400);
assert.equal(payload.id, "mismatch-1");
assert.equal(payload.error.code, -32020);
assert.deepEqual(lab.dispatch, before);

These controls protect MCP envelope consistency, and they protect it well. They do not replace application authorization: a perfectly valid request, with consistent headers, complete _meta and the right revision, can still ask for another principal's record. The protocol checks that you are speaking MCP correctly, not that you are entitled to ask for what you are asking.

[10] AWS translation

In AWS, the same separation maps as follows:

API Gateway
JWT authorizer
Lambda
tool/resource authorization
DynamoDB
PK principal · SK handle

From Map to DynamoDB: ownership in the key

The following sketch assumes API Gateway HTTP API with a JWT authorizer and payload v2: other API or authorizer types expose claims in different structures. The Lambda derives a canonical principal from iss + sub, never accepts an owner from the body, and uses that principal in the partition key. Note the detail [08] just made interesting: PK and SK are separate attributes, not a concatenated string — the boundary between owner and handle is enforced by the datastore, not by your template literal. Here the correct shape comes for free, by architecture.

AWS sketch · not executed in the lab
const claims = event.requestContext.authorizer?.jwt?.claims;
if (
  typeof claims?.iss !== "string" ||
  typeof claims?.sub !== "string"
) return unauthorized();

const principal = `${claims.iss}#${claims.sub}`;
const Key = {
  PK: `PRINCIPAL#${principal}`,
  SK: `SCAN#${handle}`
};

const { Item } = await ddb.send(new GetCommand({
  TableName,
  Key,
  ConsistentRead: true
}));

if (!Item || Item.expiresAt <= now) {
  return genericNotFound();
}

For a state transition, a condition expression prevents a second request from completing an item that has already changed. This is single-item atomicity, not evidence of end-to-end idempotency or multi-region correctness.

// architectural sketch: conditional state transition
await ddb.send(new UpdateCommand({
  TableName,
  Key,
  UpdateExpression: "SET #s = :done",
  ConditionExpression:
    "attribute_exists(PK) AND #s = :pending AND expiresAt > :now",
  ExpressionAttributeNames: { "#s": "status" },
  ExpressionAttributeValues: {
    ":done": "completed",
    ":pending": "pending",
    ":now": now
  }
}));
DynamoDB TTL is not an authorization gate

Removal of expired items is asynchronous and may happen after expiry. Therefore expiresAt must be checked on reads and, for writes, in the condition expression. A shared Lambda IAM role also does not provide row-level authorization on its own: the application key and policy remain necessary.

None of these lines is an architectural opinion: they are the same things the lab verified in-process, translated into services that send you an invoice.

[11] Hardening checklist

Nothing that follows requires a refactor. All of it requires having decided, once and in writing, who owns what.

Conclusion boundary: measured and not measured

Measured by the suite

Modern revision negotiated; bearer gate ahead of the handler; cross-read and cross-write with handle-only lookup; blocking via principal binding; concatenated-key collision and tuple-key resistance; on-access record TTL; audit against the four controlled secrets; three pre-dispatch errors.

Not measured

Real exposure through an LLM or subagent; end-to-end OAuth; credential expiry; AWS and DynamoDB; replay, idempotency and concurrency; self-contained state; MRTR requestState; interoperability and other SDKs.

[12] Limitations

[13] Lab verdict

Statelessness is useful for scaling, not a security certification. It reduces MCP lifecycle state; it does not eliminate identity, ownership, idempotency or auditing — it moves them onto your desk. And once moved, you find that even the right recommendation, written with the wrong delimiter, behaves as if you had never applied it. The session is gone. The business logic, rather stubbornly, remains.

Technical gate

npm ci, 13/13 tests and evidence generation finish with exit code zero on a clean lockfile install. The published manifest was generated from a clean worktree and records runtime, commit and source hashes: if anything in this article disagrees with that file, trust the file.

[14] Primary sources and artifact

How the evidence is built

npm run evidence reruns the tests in deterministic order, normalizes only the local path and variable timings, saves the TAP transcript, and computes SHA-256 for the harness, tests, lockfile and generator itself. The gate becomes true only with exit code zero and exactly 13 tests, 13 passes and 0 failures. The expected count lives in a single constant: adding a test without updating it fails the gate, which is precisely the desired behavior.

// excerpt from scripts/generate-evidence.mjs
const hashTargets = [
  "README.md",
  "package.json",
  "package-lock.json",
  "scripts/generate-evidence.mjs",
  "src/harness.mjs",
  ...tests
];

for (const relative of hashTargets) {
  const bytes = await readFile(path.join(root, relative));
  sourceHashes[relative] = createHash("sha256")
    .update(bytes)
    .digest("hex");
}

// single source of truth for the gate
const EXPECTED_TESTS = 13;

const publicationGate = {
  expectedTests: EXPECTED_TESTS,
  passed:
    run.status === 0 &&
    summary.tests === EXPECTED_TESTS &&
    summary.pass === EXPECTED_TESTS &&
    summary.fail === 0
};

The manifest links an output to the sources that produced it, but it is not a signed attestation and does not guarantee bit-for-bit reproducibility across runtimes. Runtime, commit and hashes do not appear in the prose precisely so they cannot diverge from it: if text and manifest disagreed, the manifest would be right. The test count is the only duplicated number, which is why on the other side it is a constant that fails the gate if someone adds a test and gets distracted. The published manifest reports sourceDirty: false: it was regenerated after the sources were committed, which is the only order in which that field means anything.

  1. Reproducible lab — README and test matrix
  2. Lab evidence — normalized TAP output
  3. Lab evidence — environment and source hashes
  4. MCP Specification 2026-07-28
  5. MCP 2026-07-28 — Key Changes
  6. MCP 2026-07-28 — Deprecated Features
  7. Streamable HTTP
  8. MCP Authorization
  9. Authorization Security Considerations
  10. Security Best Practices — State Handle Hijacking
  11. TypeScript SDK v2 — Supporting protocol revision 2026-07-28
  12. AWS API Gateway — JWT authorizers for HTTP APIs
  13. Amazon DynamoDB — Condition expressions
  14. Amazon DynamoDB — Time to Live
  15. AWS Lambda — Node.js logging