// TL;DR — il protocollo ha perso la sessione. Il sistema no.

[00] Prima il risultato

Un handle è il riferimento che un server restituisce per ritrovare un'operazione più tardi: pensalo come il numero stampato su una ricevuta. Quel numero indica quale pratica cercare; non dovrebbe decidere chi ha il diritto di aprirla. Sembra ovvio scritto così. Poi apri il codice di produzione e scopri che la ricevuta apre la cassa.

Perché

La revisione rende MCP stateless a livello di protocollo, e con la sessione se ne va anche l'abitudine di appoggiarsi a lei. La spec, in compenso, lascia un requisito normativo esplicito sugli handle applicativi. Volevamo sapere se quel requisito regge quando lo si implementa alla lettera.

Cosa abbiamo fatto

Quattro principal autenticati, un job fittizio in memoria, tre strategie di chiave. Nessuna rete, nessun account AWS, nessun bersaglio reale. L'handle è sempre un UUID v4: cambia soltanto come il record viene indicizzato.

Cosa abbiamo ottenuto

Con il lookup per solo handle, Mallory legge la nota di Alice e le modifica il job. Con la chiave concatenata <owner>:<handle> — la forma che la spec porta come esempio — un principal strutturato passa comunque, spostando il delimitatore di un segmento. Con la coppia codificata come tupla, entrambi gli attacchi finiscono su scan not found.

Il risultato in una frase

Un UUID rende l'handle difficile da indovinare; non autorizza chi lo presenta. Entropia dell'identificatore e controllo del proprietario risolvono due problemi diversi, e il secondo non si risolve allungando il primo. La suite che verifica questa differenza — e che mostra come anche il binding possa essere scritto male — ha superato 13 test su 13.

Non stiamo sostenendo di aver trovato una vulnerabilità di MCP: la debolezza vive nell'implementazione, ed è esattamente la debolezza che la spec descrive e vieta. Il contributo qui è duplice — un test eseguibile per un requisito che non ne aveva uno, e una crepa nella forma della chiave suggerita dalla spec stessa. Se ti basta questo, puoi fermarti: da [01] in poi si entra in fonti, specifica, codice e transcript.

[01] Scope e metodo

Il punto di partenza è stato un post che riassumeva la revisione come il passaggio da un MCP session-based a uno stateless. Il post è stato usato come trigger, non come fonte — la differenza tra le due cose è più o meno tutto il mestiere. Le affermazioni sono state controllate contro specifica, changelog, registro delle deprecazioni, security considerations, security best practices e guida dell'SDK TypeScript v2.

Fatto normativo

Testo MUST, SHOULD o comportamento esplicitamente dichiarato dalla specifica.

Osservazione

Output prodotto dal laboratorio, con versione, comando e transcript.

Interpretazione

Conseguenza architetturale ragionata, non garantita dal protocollo.

Limite

Condizione che restringe la validità del risultato o ne impedisce la generalizzazione.

Metodo collaborativo · 50% umano / 50% AI

Domanda di ricerca, scope, threat model e criteri sono miei, insieme all'interpretazione dei risultati e alla responsabilità di ogni conclusione. Il resto: una prima passata AI ha verificato le fonti, implementato la suite e tenuto la barra del QA. Una seconda, a contesto pulito — nel mio flusso serve esattamente a smontare quello che ho appena finito di scrivere — è andata a rileggersi le Security Best Practices e ha osservato che la variante «corretta», quella che avevo appena dichiarato tale, era aggirabile spostando un delimitatore di un segmento. Da lì è nato T13. Il pezzo più interessante di questo articolo l'ha trovato la revisione, non l'autore: motivo per cui la revisione c'è.

Perimetro del claim

Il perimetro tecnico: qui si testa il binding di un handle applicativo creato dal tool, non il campo normativo requestState delle Multi-Round Tool Requests (MRTR). MCP è un progetto vendor-neutral sotto l'Agentic AI Foundation della Linux Foundation, e questo lab non ha nulla da dire sul protocollo in sé: ha qualcosa da dire su come lo si implementa.

[02] Cosa è cambiato davvero

Prima di dire qualsiasi cosa su cosa è diventato più o meno sicuro, conviene mettere in fila cosa è effettivamente cambiato. Spoiler: quasi tutto quello che sparisce dal protocollo ricompare, con un cappello diverso, nel tuo codice applicativo.

Area 2025-11-25 2026-07-28 Implicazione security
Sessione Handshake initialize; Mcp-Session-Id opzionale su Streamable HTTP Richieste autonome Il contesto deve essere ricostruito e verificato per richiesta.
Negoziazione Handshake iniziale _meta per richiesta; server/discover facoltativo per il client Versione e capability non sono identità e non autorizzano il chiamante.
Stato multi-round Canale server→client input_required con inputRequests e/o requestState Lo stato restituito dal client va trattato come input non fidato; integrità, binding e scadenza sono responsabilità dell'implementazione.
Stream interrotto Resume/redelivery SSE Nuova richiesta e nuovo ID Retry ambigui possono richiedere idempotenza applicativa per evitare effetti duplicati.
Roots, Sampling, Logging Attivi Deprecati e presenti nella revisione La deprecazione non equivale alla rimozione; supporto e compatibilità dipendono da revisione e SDK.

Sul fronte autorizzazione la revisione stringe: validazione iss nelle authorization response, binding delle credenziali all'authorization server che le ha emesse, application_type durante il DCR. Il tutto restando opzionale (specifica). Un protocollo può alzare l'asticella quanto vuole: se l'asticella è facoltativa, qualcuno passerà comunque sotto.

The Office — they're the same picture
// «trova la differenza tra stato di sessione e stato applicativo» — sono la stessa foto

[03] Stateless non significa senza stato

La revisione rimuove lo stato di sessione del protocollo. Restano almeno altri quattro tipi di stato: identità e autorizzazione, dati del processo applicativo, deduplicazione delle operazioni e correlazione degli eventi. Non sono stati eliminati: hanno cambiato proprietario, e il nuovo proprietario sei tu.

Client
token + richiesta + _meta
Handler MCP
nuovo contesto per richiesta
Tool + datastore
business state e policy

Perché in un sistema agentico un handle non serve indovinarlo

La spec, sull'esposizione, dice una cosa sola: l'attaccante ottiene o indovina l'handle. In una web app classica quel verbo è una barriera reale: senza enumerazione o intercettazione non vai da nessuna parte. In un sistema agentico il verbo giusto è un terzo, che la spec non nomina: raccogliere. Il valore restituito da un tool attraversa il contesto del modello, un transcript, un checkpoint, una trace, una coda di retry, l'input delegato a un subagent. Nessuno di quei componenti è stato progettato come confine di sicurezza, e tutti sanno rileggere una stringa.

# 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

Sono superfici di esposizione plausibili, non leak osservati nei test: il lab non simula come l'handle cambi mano, lo assume già passato. Il punto non è che ogni orchestratore esponga questi valori. È che, se il backend autorizza tramite il solo handle, ogni componente capace di rileggerlo e ripresentarlo entra nel confine di sicurezza — compresi quelli che hai aggiunto la settimana scorsa per il debug. Ridurre la propagazione aiuta; verificare l'ownership a destinazione non è negoziabile.

This is fine — dog in burning room
// l'handle nel transcript, nel checkpoint, nella retry queue e nel prompt del subagent. tutto sotto controllo
Tesi

Lo stato non è scomparso, ha solo cambiato indirizzo. E l'indirizzo nuovo è un posto dove nessuno aveva ancora messo un controllo di autorizzazione, perché finora ci pensava la sessione.

[04] Threat model

ElementoNel laboratorio
AssetJob fittizio e nota privata associata al proprietario.
PrincipalAlice e tenant-a:svc, proprietarie; Mallory e tenant-a, autenticate ma non autorizzate sui record delle prime.
Entry pointread_scan(handle) / complete_scan(handle)
Trust boundaryToken → principal verificato; handle → record applicativo.
WeaknessLookup che ignora il proprietario, oppure che lo include in una chiave ambigua.
ImpattoLettura o modifica cross-principal: un equivalente applicativo di IDOR/BOLA sull'handle.
Come la chiama la spec

Questa non è una categoria che ci siamo inventati. La revisione 2026-07-28 la chiama State Handle Hijacking e la vieta con un MUST NOT: «MCP servers MUST NOT treat possession of a state handle as authentication». Nella stessa pagina raccomanda un SHOULD: legare l'handle lato server all'utente autenticato, «for example by keying stored state as <user_id>:<handle>», con l'id derivato dal token verificato e non fornito dal client (Security Best Practices). Il lab implementa quel SHOULD alla lettera, e poi lo rompe.

Il threat model parte da una precondizione dichiarata: chi attacca possiede già l'handle. Perché quella precondizione sia realistica, e non una comodità da laboratorio, vedi [03].

[05] Laboratorio: ambiente e regole

Il laboratorio gira interamente in-process: client e handler MCP si parlano attraverso una funzione fetch controllata, senza socket, DNS o rete. create_scan non scansiona niente — il nome è l'unica cosa aggressiva del tool: crea un oggetto in memoria con target fisso demo.local, uno stato e una nota dimostrativa.

OAuth client, client metadata e utente non sono la stessa cosa

Nel laboratorio compaiono tre identificatori che rispondono a domande diverse, e confonderli è il modo più elegante di bucarsi da soli. clientInfo descrive l'istanza software dichiarata dal client; clientId identifica il client OAuth; il claim verificato sub identifica il principal su cui applicare la policy della risorsa. Tutti i principal condividono intenzionalmente lo stesso clientId: usare quel valore come owner avrebbe fuso quattro utenti distinti in un unico dominio di autorizzazione, con l'aria di aver fatto la cosa giusta.

// 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 è una scelta esplicita della fixture, non un meccanismo di identità imposto normativamente da MCP. In produzione il mapping dipende dal verifier e dall'authorization server; il requisito architetturale è derivare il principal da credenziali verificate, mai dagli argomenti del tool o da metadati autodichiarati. Il codice completo è nel test harness.

Il gate di autenticazione viene prima dell'handler MCP

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 e T03 verificano che token assente o sconosciuto terminino con 401 prima dell'handler e senza dispatch di tool. Questo dimostra la posizione del gate nel laboratorio, non un flusso OAuth end-to-end: issuer, audience e acquisizione reale del token restano fuori scope. E soprattutto, autenticare il chiamante non autorizza automaticamente il singolo tool o il record richiesto.

# 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 test superati

Codice, lockfile, matrice dei test e output sanitizzato sono nel lab riproducibile. Le evidenze machine-readable sono disponibili come TAP e environment manifest.

[06] Baseline: sto davvero parlando MCP 2026?

Nel percorso di migrazione documentato per l'SDK TypeScript v2 il client deve fare opt-in esplicito alla revisione moderna. Prima di testare qualsiasi controllo, quindi, la baseline deve dimostrare che l'era negoziata è modern (migration guide). Sembra pedanteria. È invece l'unico modo di non pubblicare un articolo su una revisione che il proprio client non ha mai negoziato — categoria di errore silenzioso, elegante e molto imbarazzante.

# 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] Quando l'implementazione trasforma l'handle in bearer

La prima variante conserva i job in una Map condivisa a livello di processo, fuori dal contesto per-request. Usa handle opachi generati con randomUUID(), ma il lookup considera soltanto l'handle. Alice crea un job; Mallory presenta lo stesso valore restituito ad Alice. Se il server restituisce il record, la conoscenza dell'handle è diventata di fatto una credenziale bearer.

Esperimento controllato: cambia la chiave, non l'entropia

Le tre varianti usano gli stessi token, gli stessi tool, lo stesso generatore UUID v4 e gli stessi input. L'unica variabile è la chiave con cui il record viene salvato e recuperato. Così il confronto isola il controllo di ownership, senza poter dare la colpa a un identificatore debole o prevedibile — che è l'alibi preferito quando salta fuori un IDOR.

// 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 traduce il threat model in un'asserzione molto semplice: Alice crea il record; Mallory usa esattamente l'handle ricevuto da Alice; il test passa soltanto se Mallory ottiene il record e la nota privata della proprietaria. T05 ripete il percorso su una scrittura.

// 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
Risultato osservato · T04–T05

Nella variante intenzionalmente vulnerabile, Mallory — autenticata come principal distinto — ha letto la nota di Alice e modificato il suo record usando l'handle restituito ad Alice. Il lookup basato soltanto sull'handle non ha protetto né confidenzialità né integrità.

Hackerman — pixel art hacker typing
// il devastante exploit: incollare una stringa che il server ti ha appena dato

[08] Variante corretta: ownership prima dell'entropia, e una chiave che non si può piegare

La correzione non consiste nel rendere l'handle più lungo. Un UUID abbassa la probabilità di indovinarlo; non impedisce che finisca in una chat, in un log o nel prompt di un subagent, dove di probabilità non ce n'è più bisogno. Il controllo decisivo è legare il record al principal autenticato e verificare quella relazione a ogni chiamata. Il come, però, conta: è lì che la spec lascia una porta aperta.

// 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);

Negare l'attaccante senza rompere il percorso legittimo

Un test di autorizzazione negativo non basta se il controllo blocca anche il proprietario. T07 verifica entrambe le metà: Mallory non può completare il record; Alice lo vede ancora in stato pending e può completarlo. La correzione protegge la risorsa mantenendo il comportamento funzionale atteso.

// 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");
Risultato osservato · T06–T09

Il binding al principal ha negato lettura e scrittura a Mallory con lo stesso errore generico, mentre Alice ha mantenuto accesso e capacità di modifica. Verificati inoltre scadenza e cleanup on-access senza sleep, trace ID, hash SHA-256 dell'handle e assenza dei quattro segreti controllati nell'audit serializzato.

TTL deterministico: testare il tempo senza aspettarlo (e senza barare)

Usare uno sleep avrebbe reso la suite lenta e potenzialmente instabile. Il lab inietta invece una funzione clock: T08 avanza il tempo di 1.001 ms, ripresenta l'handle e verifica sia il diniego sia la rimozione on-access dalla Map. Il confine va detto con precisione: quel clock governa il TTL del record applicativo, non la scadenza della credenziale, che resta sul wall clock perché la valida il middleware bearer. Legare anche il token al clock iniettato avrebbe prodotto una suite che passa oggi e fallisce domani — il tipo di test che scopri rotto in CI di venerdì pomeriggio. Non è garbage collection periodica e non simula il TTL di un datastore distribuito: dimostra la regola applicativa implementata, e basta.

// 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");

Audit correlabile senza trasformare i log in un secondo leak

Il logger del lab usa eventi allowlist: actor, azione, decisione, motivo, trace ID e SHA-256 dell'handle. La simmetria è voluta: verso il chiamante l'errore è sempre lo stesso scan not found, così non si può distinguere «non esiste» da «non è tuo»; nell'audit interno il motivo è invece esplicito, not_found_or_not_owner oppure expired. Opaco fuori, preciso dentro — altrimenti l'oracolo che hai chiuso dalla porta principale rientra dalla finestra dei log. T09 serializza l'intero audit e controlla che non contenga l'UUID, la nota privata o i token fixture: è una proprietà verificata su quattro segreti controllati, non una garanzia universale di redazione. Un hash stabile resta correlabile e non equivale ad anonimizzazione.

// 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 produzione, per identificatori meno entropici o enumerabili, un HMAC con una chiave di correlazione separata riduce il rischio di ricostruzione offline. La chiave non va inserita nel codice o nei log; l'identità dell'actor può richiedere a sua volta pseudonimizzazione. Hash e HMAC servono alla correlazione operativa, mai all'autorizzazione.

La chiave che la spec suggerisce, e perché non basta

La Security Best Practices raccomanda di keyare lo stato «for example by keying stored state as <user_id>:<handle>». Implementato alla lettera, in JavaScript diventa `${owner}:${handle}`, ed è la cosa che chiunque scriverebbe. Il problema è che una concatenazione non conserva il confine tra i due pezzi: ("a", "b:c") e ("a:b", "c") producono la stessa stringa. Se l'id del principal può contenere il delimitatore, il confine è spostabile dal chiamante — e l'handle arriva come argomento del tool, quindi il chiamante lo controlla per intero.

Principal strutturati non sono un caso di scuola: li producono gli issuer multi-tenant, e li produce la forma canonica iss + sub che compare nello sketch AWS di [10]. Il record di tenant-a:svc con handle H finisce sotto tenant-a:svc:H. L'attaccante, autenticato come tenant-a, presenta l'handle svc:H: il server concatena e ottiene la stessa chiave. L'ownership è stata verificata. Semplicemente, il controllo ha risposto alla domanda sbagliata.

Aldo, Giovanni e Giacomo — pignolo
// «vabbè, ma il delimitatore è un dettaglio» — e infatti T13 sono quaranta righe di 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");

La correzione è smettere di unire la coppia e iniziare a codificarla: JSON.stringify([owner, handle]), oppure una chiave length-prefixed, oppure due attributi separati in un datastore che li tenga distinti per costruzione — che è esattamente ciò che fanno partition key e sort key in [10], e uno dei rari casi in cui DynamoDB ti salva senza chiederti niente. Nessuna di queste è più lenta, più lunga o più difficile della concatenazione.

Va detto con onestà: la spec scrive «for example», quindi illustra un'idea e non impone un formato. Ma un esempio dentro un documento di security best practices non viene letto come un'illustrazione — viene copiato. E finisce in produzione con lo stesso delimitatore, nello stesso punto, con l'aria di essere conforme.

Galaxy brain meme
// JSON.stringify([owner, handle]) — stessa riga, zero delimitatori da spostare
Risultato osservato · T13

A parità di byte inviati, la chiave concatenata ha restituito il record e la nota privata di un altro principal; la chiave a tupla ha risposto scan not found. Entrambe le varianti verificano l'ownership: solo una la verifica in modo non ambiguo.

Handle server-side o stato self-contained?

Il laboratorio verifica soltanto il primo modello: un handle opaco punta a un record autorevole conservato dal server. Un'architettura stateless può anche restituire al client uno stato self-contained firmato o cifrato, ma sposta i controlli invece di eliminarli.

ModelloCosa viaggia nel clientVantaggioControlli e costi
Server-side Handle opaco Revoca, TTL, one-time use e aggiornamenti su un record autorevole. Storage, lookup, consistenza e cleanup; ownership verificata a ogni accesso.
Self-contained Claim e payload protetti Meno lookup per ricostruire lo stato; compute più facilmente stateless. Binding a principal, tool, audience e scadenza; MAC/firma, eventuale AEAD, rotazione chiavi e controllo replay.
Sketch architetturale · non eseguito nel 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

Firma o cifratura possono proteggere integrità e confidenzialità, ma non autorizzano automaticamente il chiamante e non impediscono da sole il replay. Questo schema è guida progettuale: non è stato eseguito e non coincide con una prova sul campo normativo requestState delle MRTR.

[09] I controlli che il protocollo fornisce davvero

La nuova revisione definisce errori normativi per fermare queste incoerenze prima del dispatch: -32020 per il mismatch tra header e body, -32602 per l'envelope moderno privo di _meta obbligatorio e -32022 per una versione non supportata (specifica). Il laboratorio li ha osservati con createMcpHandler dell'SDK TypeScript 2.0.0 (guida SDK). In tutti e tre i casi il contatore del tool è rimasto invariato: nessun dispatch. Questa è la parte in cui il protocollo fa il suo lavoro, e lo fa bene — vale la pena dirlo, visto che fin qui l'ho usato come sfondo per i guai di qualcun altro.

TestManipolazioneEsito previstoEsito osservato
Header/bodyMcp-Name: read_scan + body complete_scan400 / -32020; dispatch invariatoPASS · T10
EnvelopeHeader moderno con _meta assente400 / -32602PASS · T11
VersioneHeader e _meta con la stessa revisione futura400 / -32022PASS · T12

Un controllo pre-dispatch, visto come test

T10 salva i contatori dei tool, invia intenzionalmente read_scan nell'header e complete_scan nel body, poi controlla status HTTP, request ID, codice JSON-RPC e assenza di dispatch. In questo modo il test non si limita a cercare un errore: dimostra che il tool incoerente non è stato eseguito.

// 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);

Questi controlli proteggono la coerenza dell'envelope MCP, e la proteggono bene. Non sostituiscono l'autorizzazione applicativa: una request perfettamente valida, con header coerenti, _meta completo e revisione giusta, può comunque chiedere il record di un altro principal. Il protocollo verifica che tu stia parlando MCP correttamente, non che tu abbia il diritto di chiedere quello che stai chiedendo.

[10] Traduzione AWS

In AWS, la stessa separazione si traduce così:

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

Dalla Map a DynamoDB: ownership nella chiave

Lo sketch seguente assume API Gateway HTTP API con JWT authorizer e payload v2: altri tipi di API o authorizer espongono i claim in strutture diverse. La Lambda ricava un principal canonico da iss + sub, non accetta un owner dal body e usa quel principal nella partition key. Nota il dettaglio che [08] ha reso interessante: PK e SK sono attributi separati, non una stringa concatenata — il confine tra proprietario e handle è imposto dal datastore, non dal tuo template literal. Qui la forma corretta si ottiene per architettura, senza doverci pensare.

Sketch AWS · non eseguito nel laboratorio
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();
}

Per una transizione di stato, una condition expression impedisce che una seconda richiesta completi nuovamente un item già modificato. È atomicità sul singolo item, non una prova di idempotenza end-to-end o di correttezza multi-region.

// 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 non è un authorization gate

La rimozione degli item scaduti è asincrona e può avvenire dopo la scadenza. Per questo expiresAt va controllato nel read e, per una scrittura, nella condition expression. Anche una IAM role condivisa dalla Lambda non fornisce da sola row-level authorization: chiave e policy applicativa restano necessarie.

Nessuna di queste righe è un'opinione architetturale: sono le stesse cose che il lab ha verificato in-process, tradotte in servizi che però mandano fattura.

[11] Checklist di hardening

Niente di quello che segue richiede un refactor. Tutto richiede di aver deciso, una volta e per iscritto, chi è il proprietario di cosa.

Confine delle conclusioni: misurato e non misurato

Misurato dalla suite

Revisione moderna negoziata; gate bearer prima dell'handler; cross-read e cross-write con lookup per solo handle; blocco con binding al principal; collisione della chiave concatenata e tenuta della chiave a tupla; TTL on-access del record; audit sui quattro segreti controllati; tre errori pre-dispatch.

Non misurato

Esposizione reale via LLM o subagent; OAuth end-to-end; scadenza delle credenziali; AWS e DynamoDB; replay, idempotenza e concorrenza; stato self-contained; requestState delle MRTR; interoperabilità e altri SDK.

[12] Limiti

[13] Verdetto del laboratorio

Stateless è una proprietà utile per scalare, non una certificazione di sicurezza. Riduce lo stato del lifecycle MCP; non elimina identità, ownership, idempotenza e audit — li sposta sulla tua scrivania. E quando li sposti, scopri che anche la raccomandazione giusta, scritta con il delimitatore sbagliato, si comporta come se non l'avessi mai applicata. La sessione è sparita. La business logic, con una certa ostinazione, è rimasta.

Gate tecnico

npm ci, 13/13 test e generazione delle evidenze terminano con exit code zero su installazione pulita da lockfile. Il manifest pubblicato è stato generato su worktree pulita e registra runtime, commit e hash delle sorgenti: se qualcosa in questo articolo non torna con quel file, fidati del file.

[14] Fonti primarie e artifact

Come viene costruita l'evidenza

npm run evidence riesegue i test in ordine deterministico, normalizza soltanto percorso locale e tempi variabili, salva il transcript TAP e calcola SHA-256 di harness, test, lockfile e generatore stesso. Il gate diventa vero esclusivamente con exit code zero ed esattamente 13 test, 13 pass e 0 fail. Il numero atteso vive in una sola costante: aggiungere un test senza aggiornarla fa fallire il gate, che è precisamente il comportamento desiderato.

// 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
};

Il manifest collega un output alle sorgenti che lo hanno prodotto, ma non è un'attestazione firmata e non garantisce riproducibilità bit-per-bit tra runtime diversi. Runtime, commit e hash non compaiono nel testo proprio per non poter divergere da lui: se testo e manifest dicessero cose diverse, avrebbe ragione il manifest. Il conteggio dei test è l'unico numero duplicato, ed è il motivo per cui dall'altra parte è una costante che fa fallire il gate se qualcuno aggiunge un test e si distrae. Il manifest pubblicato riporta sourceDirty: false: è stato rigenerato dopo il commit delle sorgenti, che è l'unico ordine in cui quel campo significa qualcosa.

  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