Questo è tutto ciò che il server MCP di osFoundry offre a un client che vuole sapere quando qualcosa cambia. Eseguito da questo Mac, senza 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
Tutte e tre le strade per il push sono chiuse. initialize dichiara i tool e nient'altro, con listChanged: false, quindi nemmeno la lista dei tool segnalerà un cambiamento. resources/subscribe risponde "Method not found". Un GET sull'URL MCP, dove un server Streamable HTTP aprirebbe uno stream dal server al client, restituisce 405. Resta un solo tool in sola lettura:
{"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"]}]}
Una risorsa "one granted", "resume from an event sequence". Il resto del post parla di queste parole, con il lato client ricostruito in Python. Qui non c'è nessun binario codex installato, quindi è lo script a fare da client. Il codice del server di osFoundry non è pubblico: le parti 1 e 2 sono il server live, la parte 3 è una ricostruzione con formati di risposta e tassi di attività propri.
Perché polling e non push, proprio con Codex?
Codex CLI non è un daemon. Gira quando lo lanci, fa un turno e poi esce o resta in attesa. Un canale push avrebbe bisogno di qualcosa in ascolto tra una sessione e l'altra, e su un portatile spesso non c'è niente. Un cursore di sequenza si adatta a questo modo di lavorare: il client tiene un intero per risorsa, torna quando vuole e chiede cosa è successo dopo.
I millisecondi qui sopra sono misurati dal Giappone su un Apple M1, quindi dicono più sul percorso di rete che sul server. Conta di più l'header di rate limit. Le risposte non autenticate riportano X-RateLimit-Limit: 2000 con un reset circa 61 s più avanti. Non è stato verificato se un client autenticato abbia lo stesso budget, ma anche fare polling su 30 risorse ogni 30 s significa 60 richieste al minuto. A limitare un watcher basato su Codex non è il carico sul server, ma i suoi stessi turni: ogni poll è una chiamata a un tool che il modello deve fare e leggere.
Come scopre un client cosa può interrogare?
Con due chiamate, e gli schema dicono molto sul design:
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 non accetta alcun argomento, e lo schema chiude la porta all'aggiunta di uno:
list_granted_resources no arguments ok
list_granted_resources project_id passed REJECT additionalProperties
È il server che dice al client cosa ha, non il client che chiede qualcosa. Le istruzioni di initialize iniziano con "Use list_granted_resources first." La risposta comprende i progetti aggiunti alla connessione e le risorse concesse singolarmente. Nel client web (ExternalAssistantsPanel.tsx) corrispondono alla sezione Projects e a "Older individual access".
list_project_resources richiede un resource_type. Non esiste un elenco di "tutto ciò che c'è in questo progetto", quindi un inventario completo costa una chiamata per tipo, nove tipi, e di più quando un tipo supera i 50 elementi (limit massimo 50, paginazione con offset). La sua descrizione aggiunge l'unica eccezione all'accesso per progetto: "Ordinary Message channels require a separate invitation."
graph_session si può interrogare ma non elencare. I Graph vivono sul desktop del proprietario e arrivano tramite list_graph_sessions, e solo dopo che il proprietario li ha condivisi dall'app desktop.
Perché il cursore è del client e non del server?
osFoundry ha entrambi i tipi. wait_for_messages, sulla rete di agenti, tiene un cursore per te: "Without after_sequence it resumes where your last call stopped", e fa long polling con timeout_s fino a 50. poll_resource_events non ha né l'uno né l'altro. timeout_s viene rifiutato, e la descrizione dice solo "resume from", quindi il numero lo porta il client:
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 cursore lato server è comodo con un solo listener. Si rompe quando due client condividono una connessione, che è esattamente ciò che succede quando apri Codex in due terminali con lo stesso login. La ricostruzione fa passare 600 eventi di canale davanti a due terminali che fanno polling a turno, in ordine casuale:
two terminals, client cursor: A saw 600/600, B saw 600/600
two terminals, server cursor: A saw 305/600, B saw 295/600
Con un cursore per connessione lato server, ogni terminale lo fa avanzare al posto dell'altro e ciascuno vede circa la metà. Con after_sequence nella richiesta, entrambi vedono tutto e il server non conserva alcuno stato per lettore. Per il log di una risorsa che più tool possono leggere in modo indipendente, è il client il proprietario giusto del cursore.
Lo schema non rende obbligatorio after_sequence e non dice cosa significhi ometterlo. Non farci affidamento. Salva il numero e invialo sempre.
Quante chiamate costa una pagina piena?
limit arriva a 100. Dopo un import massivo, una nota può avere centinaia di eventi in attesa. Svuotarli significa richiamare dall'ultima sequenza finché non torna una pagina incompleta:
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
Chiedi 100. Nella ricostruzione la risposta pesa 17 token per una pagina vuota e 41 per un evento, contati con cl100k_base. Sono i formati della ricostruzione, non quelli di osFoundry, ma l'ordine di grandezza basta: un poll vuoto costa poco da leggere, quindi il costo del polling sta soprattutto nel numero di turni.
Ogni quanto dovrebbe Codex interrogare 30 risorse?
La ricostruzione assegna a un progetto 30 risorse dei nove tipi elencabili, con attività sbilanciata: 3 molto attive (un evento al minuto), 7 tiepide (uno ogni 10 minuti), 20 fredde (uno ogni 2 ore). Questi tassi sono un'ipotesi. Simula una giornata di 8 ore con cinque seed, su un orologio simulato:
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
Ogni 30 s fissi è veloce e sprecato al 95%: 3,600 chiamate all'ora, quasi tutte vuote. Ogni 5 minuti fissi è economico e lento ovunque, anche sul canale più attivo. Il backoff per risorsa, che raddoppia l'intervallo di una risorsa dopo ogni poll vuoto e lo azzera al primo evento, si avvicina al numero di chiamate del piano economico con un ritardo mediano vicino a quello veloce. Succede perché le risorse attive restano a 30 s e quelle fredde si diradano. Il prezzo è il tetto massimo: con 30 minuti, il primo cambiamento di una risorsa fredda può aspettare mezz'ora.
Per Codex questa tabella è soprattutto un argomento contro l'uso di un timer. Quando qualcuno chiede "cosa è cambiato?", un passaggio con i cursori salvati costa una chiamata per risorsa osservata e risponde con zero ritardo per tutto ciò che è successo fino a quel momento. Il piano con backoff serve a uno script che tiene Codex, o qualsiasi altra cosa, in osservazione.
Cosa succede quando il proprietario revoca l'accesso?
Il proprietario rimuove il progetto a 4 h 07 min. Tutti i 30 cursori ora puntano a risorse che la connessione non può leggere. La domanda è per quanto tempo il client continua a interrogarle:
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
Controllare i permessi con un timer o costa chiamate o brucia migliaia di poll falliti. Trattare il primo poll rifiutato come un "rileggi i permessi" costa un errore e un list_granted_resources in più. Poi elimina ogni cursore la cui risorsa non compare più nell'elenco. Se il proprietario riaggiunge il progetto in seguito, il client riparte da zero su quelle risorse.
Dove gli schema fermano un client?
I casi rimanenti, verificati con jsonschema (Draft 2020-12) contro gli inputSchema live:
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" è la prima cosa che quasi tutti provano. Un progetto non è una risorsa con un log. È il permesso che rende visibili le risorse.
L'intero script ha impiegato 20.2 s di tempo reale sull'M1, comprese 40 chiamate di rete. Il tempo CPU utente è stato di 0.91 s.
Se lo implementi tu
- Tieni un log per risorsa e lascia che sia il client a tenere il cursore. Così due lettori sulla stessa connessione non possono rubarsi gli eventi, e il server non conserva stato dei lettori.
- Dai all'elenco dei permessi un tool dedicato senza argomenti e fai partire il client da lì. La discovery deve restituire solo ciò che il proprietario ha concesso.
- Limita la dimensione della pagina e spiega come svuotare (richiamare dall'ultima sequenza fino a una pagina incompleta).
- Fai backoff per risorsa, non globale. Le risorse attive restano veloci, quelle fredde diventano economiche.
- Al primo poll rifiutato, rileggi i permessi ed elimina i cursori spariti invece di riprovarli.
- Riscrivi il cursore dopo aver agito sugli eventi, così un crash li ripete invece di saltarli.
Come osFoundry decide quali tool riceve ogni connessione è spiegato nella pagina sul tool scope. La definizione breve del protocollo è nella voce MCP del glossario.
Top comments (0)