Voici tout ce que le serveur MCP d'osFoundry propose à un client qui veut savoir quand quelque chose change. Lancé depuis ce Mac, sans token :

$ uv run --with tiktoken --with jsonschema python3 watch_sim.py == Part 1: live wire, 8 calls each, ms == initialize HTTP 200 median 414.7 p90 757.8 tools/list HTTP 200 median 606.8 p90 716.7 resources/subscribe HTTP 200 median 450.6 p90 550.2 GET (stream) HTTP 405 median 423.9 p90 475.4 tools/call poll (no token) HTTP 401 median 322.0 p90 463.9 initialize capabilities: {"tools": {"listChanged": false}} resources/subscribe -> {"code": -32601, "message": "Method not found"} GET body -> '' poll without token -> isError True | Authentication required rate limit headers: limit 2000, reset in 61 s 

Les trois voies de push sont fermées. initialize déclare des outils et rien d'autre, avec listChanged: false, donc même la liste d'outils ne signalera aucun changement. resources/subscribe renvoie "Method not found". Un GET sur l'URL MCP, là où un serveur Streamable HTTP ouvrirait un flux du serveur vers le client, renvoie 405. Il reste un seul outil en lecture seule :

{"name": "poll_resource_events", "description": "Resume from an event sequence for one granted channel, Note, knowledge base, record, or session.", "inputSchema": {"properties": { "resource_type": {"enum": ["message_channel", "note", "knowledge_base", "record_channel", "room", "gitnest_repository", "agent_session", "team_session", "loop_session", "graph_session"]}, "resource_id": {"type": "string"}, "after_sequence": {"type": "integer", "minimum": 0}, "limit": {"type": "integer", "minimum": 1, "maximum": 100}}, "required": ["resource_type", "resource_id"], "additionalProperties": false}, "annotations": {"readOnlyHint": true, "openWorldHint": false, "destructiveHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:read"]}]} 

Une ressource "one granted", "resume from an event sequence". Le reste de ce post porte sur ces mots, avec le côté client reconstruit en Python. Aucun binaire codex n'est installé ici, donc c'est le script qui joue le client. Le code serveur d'osFoundry n'est pas public : les parties 1 et 2 sont le serveur en production, la partie 3 est une reconstruction avec ses propres formats de réponse et ses propres taux d'activité.

Pourquoi du polling plutôt que du push, pour Codex en particulier ?

Codex CLI n'est pas un daemon. Il tourne quand vous le lancez, fait un tour, puis s'arrête ou vous attend. Un canal de push aurait besoin de quelque chose qui écoute entre vos sessions, et sur un portable, il n'y a souvent rien. Un curseur de séquence colle à ce mode de vie : le client garde un entier par ressource, revient quand il veut et demande ce qui s'est passé après.

Les millisecondes ci-dessus sont mesurées depuis le Japon sur un Apple M1, elles en disent donc plus sur la route que sur le serveur. L'en-tête de rate limit compte davantage. Les réponses non authentifiées portent X-RateLimit-Limit: 2000 avec un reset environ 61 s plus tard. Le budget d'un client connecté n'a pas été vérifié, mais même interroger 30 ressources toutes les 30 s ne fait que 60 requêtes par minute. Ce qui limite une surveillance faite avec Codex, ce n'est pas la charge du serveur, ce sont ses propres tours : chaque poll est un appel d'outil que le modèle doit faire puis lire.

Comment un client trouve-t-il ce qu'il a le droit d'interroger ?

Avec deux appels, et les schémas en disent long sur la conception :

list_granted_resources osfoundry:read ro=True required=[] props=[] list_project_resources osfoundry:read ro=True required=['project_id', 'resource_type'] props=['project_id', 'resource_type', 'search', 'offset', 'limit'] poll_resource_events osfoundry:read ro=True required=['resource_type', 'resource_id'] props=['resource_type', 'resource_id', 'after_sequence', 'limit'] 59 tools on server; these three = 591 of 12662 tokens (4.7%, cl100k_base) poll_resource_events types: 10 | list_project_resources types: 9 | only pollable: ['graph_session'] 

list_granted_resources ne prend aucun argument, et le schéma ferme la porte à tout ajout :

list_granted_resources no arguments ok list_granted_resources project_id passed REJECT additionalProperties 

C'est le serveur qui dit au client ce qu'il a, pas le client qui demande quelque chose. Les instructions d'initialize commencent par "Use list_granted_resources first." La réponse contient les projets ajoutés à la connexion et les ressources accordées individuellement. Dans le client web (ExternalAssistantsPanel.tsx), ce sont la section Projects et "Older individual access".

list_project_resources exige un resource_type. Il n'existe pas de listing « tout ce qu'il y a dans ce projet », donc un inventaire complet coûte un appel par type, neuf types, et davantage quand un type dépasse 50 éléments (limit maximum 50, pagination avec offset). Sa description ajoute la seule exception à l'accès par projet : "Ordinary Message channels require a separate invitation."

graph_session peut être interrogé mais pas listé. Les Graphs vivent sur le bureau du propriétaire et arrivent via list_graph_sessions, et seulement après que le propriétaire les a partagés depuis l'app desktop.

Pourquoi le curseur appartient-il au client et pas au serveur ?

osFoundry a les deux. wait_for_messages, sur le réseau d'agents, garde un curseur pour vous : "Without after_sequence it resumes where your last call stopped", et il fait du long polling avec un timeout_s jusqu'à 50. poll_resource_events n'a ni l'un ni l'autre. timeout_s est rejeté, et sa description dit seulement "resume from", donc c'est le client qui apporte le nombre :

wait_for_messages props: ['after_sequence', 'timeout_s', 'limit'] timeout_s max 50 | poll_resource_events has timeout: False poll_resource_events timeout_s 30 REJECT additionalProperties 

Un curseur côté serveur est pratique pour un seul écouteur. Il casse dès que deux clients partagent une connexion, et c'est exactement ce qui arrive quand vous ouvrez Codex dans deux terminaux avec le même login. La reconstruction fait passer 600 événements de canal devant deux terminaux qui interrogent à tour de rôle, au hasard :

two terminals, client cursor: A saw 600/600, B saw 600/600 two terminals, server cursor: A saw 305/600, B saw 295/600 

Avec un curseur par connexion côté serveur, chaque terminal le fait avancer à la place de l'autre, et chacun en voit à peu près la moitié. Avec after_sequence dans la requête, les deux voient tout, et le serveur ne garde aucun état par lecteur. Pour le log d'une ressource que plusieurs outils peuvent lire indépendamment, c'est le client qui doit posséder le curseur.

Le schéma ne rend pas after_sequence obligatoire et ne dit pas ce que signifie son absence. Ne comptez pas dessus. Stockez le nombre et envoyez-le à chaque fois.

Combien d'appels coûte une page pleine ?

limit s'arrête à 100. Après un import en masse, une note peut avoir des centaines d'événements en attente. Pour tout vider, il faut rappeler depuis la dernière séquence jusqu'à recevoir une page incomplète :

burst of 350 events, limit 100: 4 calls to drain, cursor ends at 350 burst of 350 events, limit 50: 7 calls to drain, cursor ends at 350 burst of 350 events, limit 20: 18 calls to drain, cursor ends at 350 

Demandez 100. Dans la reconstruction, la réponse fait 17 tokens pour une page vide et 41 pour un événement, comptés avec cl100k_base. Ce sont les formats de la reconstruction, pas ceux d'osFoundry, mais l'ordre de grandeur suffit : un poll vide coûte peu à lire, donc le coût du polling tient surtout au nombre de tours.

À quelle fréquence Codex devrait-il interroger 30 ressources ?

La reconstruction donne à un projet 30 ressources des neuf types listables, avec une activité déséquilibrée : 3 très actives (un événement par minute), 7 tièdes (un toutes les 10 minutes), 20 froides (un toutes les 2 heures). Ces taux sont une hypothèse. Elle simule une journée de 8 heures sur cinq seeds, avec une horloge simulée :

every 30 s, all 30 calls/hour 3600 empty 94.7% seen late median 14.9 s p90 27.0 s max 30 s (1843 events/day) every 5 min, all 30 calls/hour 360 empty 78.0% seen late median 150.9 s p90 271.1 s max 300 s (1843 events/day) 30 s, x2 when empty, cap 10 min calls/hour 502 empty 70.2% seen late median 34.9 s p90 206.4 s max 599 s (1843 events/day) 30 s, x2 when empty, cap 30 min calls/hour 420 empty 65.3% seen late median 35.6 s p90 238.4 s max 1799 s (1843 events/day) server window for comparison: 2000 requests per ~61 s 

Toutes les 30 s fixes, c'est rapide et gaspillé à 95 % : 3,600 appels par heure, presque tous vides. Toutes les 5 minutes fixes, c'est économique mais lent partout, y compris sur le canal le plus actif. Le backoff par ressource, qui double l'intervalle d'une ressource après chaque poll vide et le réinitialise au premier événement, s'approche du nombre d'appels du plan économique avec un délai médian proche du plan rapide. C'est parce que les ressources actives restent à 30 s et que les froides s'espacent. Le prix, c'est le plafond : avec 30 minutes, le premier changement d'une ressource froide peut attendre une demi-heure.

Pour Codex, ce tableau plaide surtout contre l'idée même d'un timer. Quand quelqu'un demande « qu'est-ce qui a changé ? », un passage avec les curseurs stockés coûte un appel par ressource surveillée et répond sans aucun délai pour tout ce qui s'est passé jusque-là. Le plan avec backoff, c'est pour un script qui fait surveiller Codex, ou n'importe quoi d'autre, en continu.

Que se passe-t-il quand le propriétaire retire l'accès ?

Le propriétaire retire le projet à 4 h 07 min. Les 30 curseurs pointent désormais vers des ressources que la connexion ne peut plus lire. La question est de savoir combien de temps le client continue à les interroger :

owner removes the project at 4 h 07 min (30 resources lose access): re-list grants every 10 min failed polls after removal 180, list_granted_resources calls 25 re-list grants every 60 min failed polls after removal 3180, list_granted_resources calls 5 re-list grants on first error failed polls after removal 1, list_granted_resources calls 5 

Vérifier les accès sur un timer coûte soit des appels, soit des milliers de polls ratés. Traiter le premier poll refusé comme un signal « relire les accès » coûte un échec et un list_granted_resources supplémentaire. Ensuite, supprimez tous les curseurs dont la ressource n'apparaît plus. Si le propriétaire rajoute le projet plus tard, le client reprend ces ressources de zéro.

Où les schémas arrêtent-ils un client ?

Les cas restants, vérifiés avec jsonschema (Draft 2020-12) contre les inputSchema en production :

list_project_resources no resource_type REJECT required list_project_resources limit 50 ok list_project_resources limit 51 REJECT maximum: limit list_project_resources graph_session REJECT enum: resource_type list_project_resources search 101 chars REJECT maxLength: search poll_resource_events no after_sequence ok poll_resource_events graph_session ok poll_resource_events after_sequence -1 REJECT minimum: after_sequence poll_resource_events limit 100 ok poll_resource_events limit 101 REJECT maximum: limit poll_resource_events resource_type project REJECT enum: resource_type 

resource_type: "project" est ce que tout le monde essaie en premier. Un projet n'est pas une ressource avec un log. C'est l'accès qui rend les ressources visibles.

Le script complet a pris 20.2 s en temps réel sur le M1, 40 appels réseau compris. Le temps CPU utilisateur était de 0.91 s.

Si vous montez ça vous-même

  • Tenez un log par ressource et laissez le client garder le curseur. Deux lecteurs sur une même connexion ne peuvent alors plus se voler des événements, et le serveur ne stocke aucun état de lecteur.
  • Donnez à la liste des accès son propre outil sans argument, et faites démarrer le client par là. La découverte ne doit renvoyer que ce que le propriétaire a accordé.
  • Plafonnez la taille de page et indiquez comment vider (rappeler depuis la dernière séquence jusqu'à une page incomplète).
  • Faites le backoff par ressource, pas globalement. Les ressources actives restent rapides, les froides deviennent bon marché.
  • Au premier poll refusé, relisez les accès et supprimez les curseurs disparus au lieu de réessayer.
  • Réécrivez le curseur une fois que vous avez traité les événements, pour qu'un crash les rejoue au lieu de les sauter.

La façon dont osFoundry décide quels outils reçoit chaque connexion est expliquée sur la page tool scope. La définition courte du protocole se trouve dans l'entrée MCP du glossaire.