Ce que Claude Code envoie vraiment quand il propose une app à un Room osFoundry A developer documented the MCP tool schema that Claude Code calls when proposing an app to an osFoundry cloud Room, publishing the production `tools/list` output for `propose_room_snippet_app` and four companion tools. Testing with a Python substitute client against the real schema, the developer found that half of invalid calls pass JSON Schema validation and require server-side rules, including a mandatory `src/App.tsx` entry point and rejection of unsafe relative paths. The write tools carry `destructiveHint: true` and `osfoundry:write` OAuth scopes, which triggers Claude Code's permission prompt before any proposal is submitted for human review. Voici l'outil que Claude Code appelle pour placer une petite app dans un Room cloud d' osFoundry https://osfoundry.io/fr , 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. 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 : bash $ 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. 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. 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. 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. bash $ 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. 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. 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. Le côté Room, avec la barre d'onglets et les réglages, est sur la page Room https://osfoundry.io/fr/platform/rooms-detail . Pour le protocole en général, voir l' entrée du glossaire sur MCP https://osfoundry.io/fr/glossary/mcp .