Voici l'outil que Claude Code appelle pour placer une petite app dans un Room cloud d'osFoundry, tel que le serveur en production le liste dans tools/list (récupéré sans token, réduit aux parties qui comptent) :

{ "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"]}] } 

Quatre autres outils Room l'accompagnent : read_room, list_room_apps (limit 1–50), read_room_app_status (qui demande room_id et instance_id) et propose_room_repo_app (un repository_id et un version_id au lieu de files). Les trois outils de lecture sont osfoundry:read et readOnlyHint: true. Les deux outils de proposition sont osfoundry:write et destructiveHint: true, et c'est ce qui pousse Claude Code à demander la permission avant de les appeler.

Tout est dans le verbe. L'outil ne crée rien dans le Room. Il crée une proposition, et c'est une personne qui la transforme en onglet du Room. Au programme : ce que le schéma impose et ce qu'il laisse au serveur, ce que pèse une proposition, pourquoi la clé d'idempotence est obligatoire et comment un client devrait attendre un humain. Tout a été mesuré avec un client de substitution en Python, faute de binaire claude installé sur ce Mac.

Qu'est-ce que le schéma bloque, et qu'est-ce qu'il laisse au serveur ?

room_proto.py valide les appels avec jsonschema contre l'inputSchema réel ci-dessus (enregistré sous tools-list.json). Deux règles supplémentaires découlent de la façon dont osFoundry traite les propositions de snippets. Le point d'entrée du runtime du Room, src/App.tsx, doit être présent. Et chaque chemin doit être un chemin relatif sûr. Le script applique les deux en plus du schéma :

$ 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 

La moitié des appels invalides passent le JSON Schema. Les clés de chemin d'un objet files ne peuvent pas être contraintes avec additionalProperties. Il faudrait propertyNames avec un pattern, et un pattern qui rejette .. après normalisation devient vite illisible. Le schéma dit donc « map de strings » et c'est le serveur qui juge les clés. En revanche, le additionalProperties: false au niveau racine fait un vrai travail. Un modèle qui invente "visibility": "public" reçoit une erreur de schéma au lieu d'un champ ignoré en silence.

Si vous développez un client, faites la même vérification avant l'appel. Une proposition rejetée coûte un aller-retour et, pire encore, les tokens de sortie nécessaires pour réécrire les fichiers.

Combien pèse une proposition ?

L'app de l'exemple est un minuteur de stand-up : src/App.tsx (63 lignes) et src/styles.css (14 lignes).

== 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'enveloppe JSON-RPC ajoute 11.9% au code source brut, surtout à cause des retours à la ligne et des guillemets échappés. Ce n'est pas le coût qui compte. Chaque octet de files est de la sortie du modèle : Claude Code écrit le fichier, puis le réécrit en argument de l'outil. Une proposition à deux fichiers représente environ 1,100 tokens d'arguments. Une app à 21 fichiers, obtenue en répétant le même composant, en représente 14,143. Les tokens sont comptés avec tiktoken cl100k_base, le tokenizer d'OpenAI, donc à prendre comme des ordres de grandeur.

Le schéma ne publie aucune limite en octets pour files. En pratique, la limite, c'est la personne qui relit. La carte d'approbation affiche chaque fichier en entier sous « Review all proposed files (N) », chacun dans sa propre zone de défilement. Personne ne lit 21 fichiers sur une carte. Pour une app de cette taille, propose_room_repo_app convient mieux : il pointe vers une version épinglée d'un dépôt GitNest du même projet, et la carte renvoie vers « Review repository code before approving » au lieu de coller le code.

Que garantit l'étape de revue ?

La description de l'outil le dit clairement : le snippet « appears in the cloud Room launcher only after its owner reviews and approves the full code ». Dans le Room, le launcher, c'est la barre d'onglets des apps. La carte indique « Approval makes this code available in the Room app launcher. Review the files before approving. » « Approve and create » insère l'onglet. « Decline » l'écarte. Les membres du Room ne peuvent pas ouvrir une proposition en attente, donc un snippet non relu ne s'exécute jamais dans la session de qui que ce soit.

Le problème des retries retombe donc sur le client. Avec MCP sur HTTP, une réponse peut se perdre alors que le serveur a déjà agi. Si le client relance alors avec une nouvelle clé, le propriétaire reçoit deux cartes pour une seule app. room_proto.py simule ce cas avec un serveur en mémoire qui stocke les propositions par clé, 1,000 propositions et 5% de chances que chaque réponse se perde :

== 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 

Avec un nouvel UUID à chaque tentative, 74 des 1,074 cartes sont des doublons que le propriétaire doit repérer et refuser. Avec une clé dérivée du contenu, "snip-" + sha256(room_id, name, files)[:32], un retry porte la même clé et le nombre de doublons tombe à 0. Une modification volontaire des fichiers produit une nouvelle clé et une nouvelle carte, ce qui est exactement ce qu'on veut. Le minimum de 8 caractères du schéma écarte les clés du genre "1". Dans aucun des deux runs, rien n'a atteint le launcher avant l'approbation. Ça, c'est le travail du serveur, pas du client.

Comment Claude Code devrait-il attendre le propriétaire ?

initialize renvoie "capabilities": {"tools": {"listChanged": false}} et rien d'autre. Il n'y a aucun push du type « votre proposition a été approuvée ». Le client a trois options. Il peut demander à l'utilisateur de le prévenir une fois l'approbation faite, il peut faire du polling sur read_room_app_status (ou sur list_room_apps jusqu'à ce que la nouvelle instance apparaisse), ou il peut reprendre poll_resource_events pour le Room, qui accepte resource_type: "room". Sans token, je n'ai pas vérifié quels événements une approbation y écrit, donc la simulation interroge le statut.

Le temps de revue humain est la donnée d'entrée. Le script le tire d'une loi log-normale de médiane 6 minutes, avec un budget d'une heure. C'est une hypothèse, pas une mesure, mais la forme (la plupart répondent en quelques minutes, quelques-uns mettent une demi-heure) est la partie réaliste :

== 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 toutes les 5 secondes repère l'approbation en moins de 5 s, pour 112 appels par proposition, et chaque appel est un tour du modèle dépensé en sortie d'outil. Un intervalle fixe de 30 secondes, c'est 19 appels avec 28 secondes dans le pire cas. Un backoff plafonné à 2 minutes descend à 11 appels, avec une latence p95 de 111 s. 14 des 2,000 revues ont pris plus d'une heure, et toutes les stratégies ont abandonné de la même façon. Pour une session interactive, la réponse correcte la moins chère, c'est zéro appel : terminer le tour avec « approve it under Connected → Needs your approval and tell me » (approuvez-la dans Connected → Needs your approval et prévenez-moi), puis faire un seul appel à list_room_apps. Le polling n'en vaut la peine que si Claude Code a autre chose à faire entre-temps.

Des allers-retours réels, pour fixer l'ordre de grandeur : dix appels non authentifiés de chaque type depuis ce Mac au Japon vers l'endpoint en production.

$ 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" 

Environ 0.6 s par appel. Une boucle de 5 secondes passe donc un dixième de son temps sur le réseau, et un tour de modèle par appel coûte bien plus que ça.

Que veut dire « build state » pour un snippet ?

read_room_app_status renvoie « the safe launcher and latest build state of one app », sans config, fichiers, logs ni secrets. Un snippet n'a pas de build côté serveur. Le Room compile ses fichiers là où l'onglet est ouvert : dans une iframe sandboxée avec Sucrase dans l'app desktop, dans une sandbox Sandpack dans le navigateur. La voie du dépôt est elle aussi épinglée, et sa carte d'approbation indique « This path does not build or deploy a bundle. » Pour les deux types de proposition, c'est l'entrée dans le launcher qu'il faut attendre. Un build state n'apparaît qu'une fois que quelqu'un déploie l'onglet, et ça se fait dans osFoundry.

Pourquoi ne peut-il pas atteindre un Room local ?

read_room répond « Local Rooms are unavailable », et initialize porte la règle générale : « Local-only data is unavailable to cloud clients. » Un Room local vit dans la base de données propre de l'app desktop et n'est jamais synchronisé. L'endpoint MCP est un service cloud qui détient un token OAuth limité au workspace, et il n'a aucun chemin vers un laptop en particulier. Pour l'atteindre, il faudrait un relais géré par l'app desktop : activé par appareil et par projet, avec un état en ligne/hors ligne et une allowlist de commandes. La surface d'outils ne prétend pas le contraire. La même frontière s'applique aux données d'un snippet. Son stockage SDK reste sur l'appareil de la personne qui utilise l'onglet, et aucun outil de lecture ne renvoie le contenu de la base.

Si vous le câblez vous-même

  • Validez les clés de chemin côté serveur, même si vous publiez un schéma. JSON Schema sait décrire « map de strings », mais pas « chemin relatif sûr », et dans ce test la moitié des appels invalides étaient des problèmes de chemin.
  • Dérivez la clé d'idempotence du contenu, pas de la tentative. Les réponses perdues ne coûtent alors rien, et une vraie modification obtient quand même sa propre carte.
  • Laissez l'attention de la personne qui relit fixer la limite de taille. Si la carte affiche chaque fichier, limitez les snippets à quelques fichiers et envoyez les apps plus grosses sous forme de version de dépôt épinglée.
  • Par défaut, ne faites pas faire de polling à l'agent pour attendre un humain. Faites-lui terminer son tour et lire le statut une seule fois quand l'utilisateur revient, et réservez le backoff aux cas où il a autre chose à faire.
  • Gardez la relecture minimale. Renvoyez l'identité et l'état du launcher, jamais la config ni les données de l'app, pour que les vérifications de statut ne deviennent pas un moyen de lire ce que les gens ont stocké.
  • Indiquez dans la description de l'outil ce qui est hors de portée (« Local Rooms are unavailable »), pour que le modèle le dise à l'utilisateur au lieu de réessayer.

Le côté Room, avec la barre d'onglets et les réglages, est sur la page Room. Pour le protocole en général, voir l'entrée du glossaire sur MCP.