Questo è il tool che Claude Code chiama per mettere una piccola app in un Room cloud di osFoundry, così come il server in produzione lo elenca in tools/list (recuperato senza token e ridotto alle parti che contano):
{ "name": "propose_room_snippet_app", "description": "Prepare a Room snippet with source files. It appears in the cloud Room launcher only after its owner reviews and approves the full code in osFoundry.", "inputSchema": { "type": "object", "properties": { "room_id": {"type": "string"}, "name": {"type": "string", "maxLength": 200}, "files": {"type": "object", "additionalProperties": {"type": "string"}}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128} }, "required": ["room_id", "name", "files", "idempotency_key"], "additionalProperties": false }, "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}] } Accanto ci sono altri quattro tool per i Room: read_room, list_room_apps (limit 1–50), read_room_app_status (richiede room_id e instance_id) e propose_room_repo_app (un repository_id e una version_id al posto di files). Tutti e tre i tool di lettura sono osfoundry:read e readOnlyHint: true. Entrambi i tool di proposta sono osfoundry:write e destructiveHint: true, ed è questo che fa chiedere conferma a Claude Code prima di chiamarli.
Il punto è il verbo. Il tool non crea niente nel Room. Crea una proposta, ed è una persona a trasformarla in una tab del Room. Di seguito: cosa impone lo schema e cosa lascia al server, quanto pesa una proposta, perché la chiave di idempotenza è obbligatoria e come un client dovrebbe aspettare un essere umano. Tutto è stato misurato con un sostituto del client scritto in Python, dato che su questo Mac non è installato nessun binario claude.
Cosa blocca lo schema, e cosa lascia al server?
room_proto.py valida le chiamate con jsonschema contro l'inputSchema reale qui sopra (salvato come tools-list.json). Altre due regole derivano da come osFoundry gestisce le proposte di snippet. L'entry point del runtime del Room, src/App.tsx, deve essere presente. E ogni percorso deve essere un percorso relativo sicuro. Lo script applica entrambe sopra lo schema:
$ uv run --with jsonschema --with tiktoken python3 room_proto.py == validation == ok PASS no App.tsx REJECT rule: src/App.tsx missing absolute path REJECT rule: unsafe path '/etc/hosts' dot-dot path REJECT rule: unsafe path 'src/../../x.ts' backslash path REJECT rule: unsafe path 'src\\x.ts' name 201 chars REJECT schema: 'nnnn…' is too long key 7 chars REJECT schema: 'abcdefg' is too short extra field REJECT schema: Additional properties are not allowed ('visibility' was unexpected) non-string file REJECT schema: {'a': 1} is not of type 'string' caught by published schema: 4, needs server rule: 4 Metà delle chiamate sbagliate supera il JSON Schema. Le chiavi di percorso in un oggetto files non si possono vincolare con additionalProperties. Servirebbe propertyNames con un pattern, e un pattern che rifiuti .. dopo la normalizzazione diventa presto brutto. Quindi lo schema dice "mappa di stringhe" e le chiavi le giudica il server. Il additionalProperties: false al livello più alto, invece, fa un lavoro vero. Un modello che si inventa "visibility": "public" riceve un errore di schema invece di un campo ignorato in silenzio.
Se costruisci un client, fai lo stesso controllo prima della chiamata. Una proposta rifiutata costa un round trip e, peggio ancora, i token di output per riscrivere i file.
Quanto pesa una proposta?
L'app dell'esempio è un timer per lo standup: src/App.tsx (63 righe) e src/styles.css (14 righe).
== size == src/App.tsx 2021 B 63 lines 583 tok src/styles.css 1046 B 14 lines 399 tok source bytes 3067, envelope bytes 3431, escape+frame overhead 11.9% argument tokens (cl100k) 1141 2 files: envelope 4463 B, 1394 tok 6 files: envelope 12919 B, 4078 tok 21 files: envelope 44639 B, 14143 tok tools/list 45368 B for 59 tools; the 5 room tools 3482 B (939 tok) L'envelope JSON-RPC aggiunge l'11.9% rispetto al sorgente grezzo, soprattutto per a capo e virgolette con escape. Non è questo il costo che conta. Ogni byte in files è output del modello: Claude Code scrive il file e poi lo riscrive come argomento del tool. Una proposta con due file sono circa 1,100 token di argomenti. Un'app da 21 file, ottenuta ripetendo lo stesso componente, ne fa 14,143. I token sono contati con tiktoken cl100k_base, il tokenizer di OpenAI, quindi vanno presi come indicativi.
Lo schema non pubblica nessun limite in byte per files. In pratica il limite è chi revisiona. La card di approvazione mostra ogni file per intero sotto "Review all proposed files (N)", ognuno nel proprio riquadro scorrevole. Nessuno legge 21 file su una card. Per qualcosa di quelle dimensioni è più adatto propose_room_repo_app: punta a una versione fissata di un repository GitNest nello stesso progetto, e la card rimanda a "Review repository code before approving" invece di incollare il codice.
Cosa garantisce il passaggio di revisione?
La descrizione del tool lo dice chiaramente: lo snippet "appears in the cloud Room launcher only after its owner reviews and approves the full code". Nel Room, il launcher è la barra delle tab delle app. La card riporta "Approval makes this code available in the Room app launcher. Review the files before approving." "Approve and create" inserisce la tab. "Decline" la scarta. I membri del Room non possono aprire una proposta in sospeso, quindi uno snippet non revisionato non gira mai nella sessione di nessuno.
Così il problema dei retry passa al client. Con MCP su HTTP una risposta può andare persa dopo che il server ha già agito. Se il client allora riprova con una chiave nuova, l'owner si ritrova due card per una sola app. room_proto.py lo simula con un server in memoria che salva le proposte per chiave, 1,000 proposte e una probabilità del 5% che ogni risposta vada persa:
== review gate, 1000 proposals, 5% lost responses == random calls 1074 owner cards 1074 duplicates 74 in launcher before approval 0 content calls 1057 owner cards 1000 duplicates 0 in launcher before approval 0 Con un UUID nuovo a ogni tentativo, 74 delle 1,074 card sono duplicati che l'owner deve riconoscere e rifiutare. Con una chiave derivata dal contenuto, "snip-" + sha256(room_id, name, files)[:32], un retry porta la stessa chiave e i duplicati sono 0. Una modifica voluta ai file produce una chiave nuova e una card nuova, che è proprio quello che vuoi. Il minimo di 8 caratteri dello schema tiene fuori chiavi come "1". In nessuna delle due esecuzioni qualcosa è arrivato nel launcher prima dell'approvazione. Quella parte è compito del server, non del client.
Come dovrebbe Claude Code aspettare l'owner?
initialize restituisce "capabilities": {"tools": {"listChanged": false}} e nient'altro. Non c'è nessun push del tipo "la tua proposta è stata approvata". Il client ha tre opzioni. Può chiedere all'utente di avvisare quando ha approvato, può fare polling su read_room_app_status (oppure su list_room_apps finché non compare la nuova istanza), oppure può riprendere poll_resource_events per il Room, che accetta resource_type: "room". Senza token non ho verificato quali eventi scriva lì un'approvazione, quindi la simulazione interroga lo stato.
L'input è il tempo di revisione umano. Lo script lo estrae da una distribuzione log-normale con mediana di 6 minuti e un budget di un'ora. È un'ipotesi, non una misura, ma la forma (quasi tutti rispondono in pochi minuti, qualcuno ci mette mezz'ora) è la parte realistica:
== polling while the owner reviews (n=2000, median review 6 min, 1 h budget) == review delay p50 357s p95 1816s fixed 5s calls mean 111.8 lag p50 2.4s p95 4.7s gave up 14 fixed 30s calls mean 19.0 lag p50 14.5s p95 28.4s gave up 14 backoff 5s x1.5 cap 120s calls mean 10.8 lag p50 47.0s p95 111.0s gave up 14 backoff 10s x2 cap 300s calls mean 6.1 lag p50 105.2s p95 273.8s gave up 14 Un polling ogni 5 secondi si accorge dell'approvazione entro 5 s, al prezzo di 112 chiamate per proposta, e ogni chiamata è un turno del modello speso sull'output del tool. Un intervallo fisso di 30 secondi fa 19 chiamate con 28 secondi nel caso peggiore. Il backoff fino a un tetto di 2 minuti scende a 11 chiamate, con un ritardo p95 di 111 s. 14 delle 2,000 revisioni hanno richiesto più di un'ora, e tutte le strategie ci hanno rinunciato allo stesso modo. In una sessione interattiva, la risposta corretta più economica è zero chiamate: chiudere il turno con "approve it under Connected → Needs your approval and tell me" (approvala in Connected → Needs your approval e fammi sapere) e poi fare una sola chiamata a list_room_apps. Il polling conviene solo quando Claude Code ha altro lavoro da fare nel frattempo.
Round trip reali, per avere un'idea delle grandezze: dieci chiamate non autenticate per tipo da questo Mac in Giappone verso l'endpoint in produzione.
$ python3 probe.py initialize HTTP 200 308 B median 680 ms min 358 max 968 tools/list HTTP 200 45368 B median 687 ms min 429 max 1253 tools/call read_room (no token) HTTP 401 337 B median 559 ms min 427 max 847 www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue" Circa 0.6 s a chiamata. Quindi un ciclo da 5 secondi passa un decimo del suo tempo in rete, e un turno del modello per ogni chiamata costa molto di più.
Cosa significa "build state" per uno snippet?
read_room_app_status restituisce "the safe launcher and latest build state of one app", senza config, file, log o secret. Uno snippet non ha una build lato server. Il Room compila i suoi file dove viene aperta la tab: in un iframe sandboxed con Sucrase nell'app desktop, in una sandbox Sandpack nel browser. Anche la strada del repository è fissata a una versione, e la sua card di approvazione dice "This path does not build or deploy a bundle." Per entrambi i tipi di proposta, quello da aspettare è la voce nel launcher. Un build state compare quando qualcuno fa il deploy della tab, e questo si fa in osFoundry.
Perché non può raggiungere un Room locale?
read_room dice "Local Rooms are unavailable", e initialize riporta la regola generale: "Local-only data is unavailable to cloud clients." Un Room locale vive nel database dell'app desktop e non viene mai sincronizzato. L'endpoint MCP è un servizio cloud con un token OAuth limitato al workspace, e non ha nessuna strada verso un laptop specifico. Per raggiungerlo servirebbe un relay gestito dall'app desktop: attivato per dispositivo e per progetto, con uno stato online/offline e una allowlist di comandi. La superficie dei tool non finge il contrario. Lo stesso confine vale per i dati dentro uno snippet. Il suo storage SDK resta sul dispositivo di chi usa la tab, e nessuno dei tool di lettura restituisce contenuti del database.
Se lo colleghi tu
- Valida le chiavi di percorso sul server anche se pubblichi uno schema. JSON Schema sa descrivere "mappa di stringhe", ma non "percorso relativo sicuro", e in questo test metà delle chiamate sbagliate erano problemi di percorso.
- Deriva la chiave di idempotenza dal contenuto, non dal tentativo. Così le risposte perse non costano nulla, e una modifica reale ottiene comunque la sua card.
- Lascia che sia l'attenzione di chi revisiona a fissare il limite di dimensione. Se la card mostra ogni file, tieni gli snippet a pochi file e manda le app più grandi come versione fissata di un repository.
- Non far fare polling all'agente per aspettare un umano come comportamento predefinito. Fagli chiudere il turno e leggere lo stato una volta quando l'utente torna, e usa il backoff solo quando ha altro lavoro.
- Tieni la lettura di ritorno essenziale. Restituisci identità e stato del launcher, mai config o dati dell'app, così i controlli di stato non diventano un modo per leggere ciò che le persone hanno salvato.
- Scrivi nella descrizione del tool cosa è fuori portata ("Local Rooms are unavailable"), così il modello lo dice all'utente invece di riprovare.
Il lato Room, con la barra delle tab e le impostazioni, è nella pagina Room. Per il protocollo in generale, vedi la voce del glossario su MCP.
