Inilah semua yang ditawarkan server MCP osFoundry kepada client yang ingin tahu kapan sesuatu berubah. Dijalankan dari Mac ini tanpa 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 Ketiga jalur push tertutup. initialize hanya mendeklarasikan tools, tidak ada yang lain, dengan listChanged: false, jadi daftar tools pun tidak akan mengumumkan perubahan. resources/subscribe mengembalikan "Method not found". GET ke URL MCP, tempat server Streamable HTTP biasanya membuka stream dari server ke client, mengembalikan 405. Yang tersisa hanya satu tool read-only:
{"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"]}]} Resource "one granted", "resume from an event sequence". Sisa tulisan ini membahas kata-kata itu, dengan sisi client yang dibangun ulang di Python. Di mesin ini tidak ada binary codex, jadi script-nya yang berperan sebagai client. Kode server osFoundry tidak publik: Part 1 dan 2 adalah server live, sedangkan Part 3 adalah rekonstruksi dengan bentuk respons dan tingkat aktivitasnya sendiri.
Kenapa polling, bukan push, khususnya untuk Codex?
Codex CLI bukan daemon. Ia jalan saat Anda menjalankannya, mengerjakan satu turn, lalu keluar atau menunggu Anda. Channel push butuh sesuatu yang terus mendengarkan di antara sesi Anda, dan di laptop sering kali tidak ada apa-apa. Cursor sequence cocok dengan pola itu: client menyimpan satu integer per resource, kembali kapan saja, dan menanyakan apa yang terjadi setelah angka itu.
Angka milidetik di atas diukur dari Jepang di Apple M1, jadi lebih banyak menggambarkan rute jaringan daripada server. Header rate limit lebih penting. Respons tanpa autentikasi membawa X-RateLimit-Limit: 2000 dengan reset sekitar 61 s ke depan. Belum dicek apakah client yang sudah login mendapat jatah yang sama, tetapi polling 30 resource setiap 30 s pun hanya 60 request per menit. Yang membatasi watcher berbasis Codex bukan beban server, melainkan turn-nya sendiri: setiap poll adalah tool call yang harus dibuat dan dibaca oleh model.
Bagaimana client tahu apa saja yang boleh di-poll?
Dengan dua call, dan schema-nya banyak bercerita tentang desainnya:
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 sama sekali tidak menerima argumen, dan schema-nya menutup kemungkinan menambahkannya:
list_granted_resources no arguments ok list_granted_resources project_id passed REJECT additionalProperties Di sini server yang memberi tahu client apa yang dimilikinya, bukan client yang meminta sesuatu. Instruksi initialize diawali dengan "Use list_granted_resources first." Jawabannya berisi project yang ditambahkan ke koneksi dan resource yang diberikan secara individual. Di web client (ExternalAssistantsPanel.tsx), keduanya adalah bagian Projects dan "Older individual access".
list_project_resources membutuhkan resource_type. Tidak ada listing "semua isi project ini", jadi inventaris lengkap berarti satu call per tipe, sembilan tipe, dan lebih banyak lagi kalau satu tipe punya lebih dari 50 item (limit maksimum 50, paging dengan offset). Deskripsinya menambahkan satu-satunya pengecualian untuk akses project: "Ordinary Message channels require a separate invitation."
graph_session bisa di-poll tetapi tidak bisa di-list. Graph tinggal di desktop owner dan masuk lewat list_graph_sessions, itu pun hanya setelah owner membagikannya dari aplikasi desktop.
Kenapa cursor dipegang client, bukan server?
osFoundry punya keduanya. wait_for_messages di jaringan agent menyimpan cursor untuk Anda: "Without after_sequence it resumes where your last call stopped", dan ia melakukan long polling dengan timeout_s sampai 50. poll_resource_events tidak punya keduanya. timeout_s ditolak, dan deskripsinya hanya bilang "resume from", jadi client yang membawa angkanya:
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 Cursor di sisi server nyaman untuk satu listener. Ia rusak saat dua client berbagi satu koneksi, dan itulah yang terjadi saat Anda membuka Codex di dua terminal dengan login yang sama. Rekonstruksi ini mengalirkan 600 event channel ke dua terminal yang bergantian melakukan poll secara acak:
two terminals, client cursor: A saw 600/600, B saw 600/600 two terminals, server cursor: A saw 305/600, B saw 295/600 Dengan satu cursor per koneksi di server, setiap terminal memajukan cursor milik terminal lain, dan masing-masing hanya melihat sekitar separuh. Dengan after_sequence di request, keduanya melihat semuanya, dan server sama sekali tidak menyimpan state per pembaca. Untuk log resource yang bisa dibaca beberapa tool secara terpisah, client adalah pemilik cursor yang tepat.
Schema tidak mewajibkan after_sequence, dan juga tidak menjelaskan apa artinya kalau dihilangkan. Jangan mengandalkannya. Simpan angkanya dan selalu kirim.
Berapa call yang dibutuhkan untuk satu halaman penuh?
limit maksimal 100. Setelah bulk import, sebuah note bisa punya ratusan event yang menunggu. Untuk menghabiskannya, panggil lagi dari sequence terakhir sampai halaman yang kembali tidak penuh:
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 Minta 100. Di rekonstruksi, ukuran respons adalah 17 token untuk halaman kosong dan 41 untuk satu event, dihitung dengan cl100k_base. Itu bentuk respons rekonstruksi, bukan milik osFoundry, tetapi orde besarnya sudah cukup menjelaskan: poll kosong murah untuk dibaca, jadi biaya polling sebagian besar ada di jumlah turn.
Seberapa sering Codex sebaiknya melakukan polling ke 30 resource?
Rekonstruksi ini memberi satu project 30 resource dari sembilan tipe yang bisa di-list, dengan aktivitas yang timpang: 3 sibuk (satu event per menit), 7 sedang (satu setiap 10 menit), 20 sepi (satu setiap 2 jam). Angka-angka ini asumsi. Simulasinya menjalankan hari kerja 8 jam dengan lima seed, di jam simulasi:
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 Tetap setiap 30 s itu cepat tetapi 95% terbuang: 3,600 call per jam, hampir semuanya kosong. Tetap setiap 5 menit itu murah tetapi lambat di mana-mana, termasuk di channel yang sibuk. Backoff per resource, yang menggandakan interval sebuah resource setelah setiap poll kosong dan me-reset-nya saat event pertama datang, mendekati jumlah call jadwal murah dengan median keterlambatan mendekati jadwal cepat. Penyebabnya, resource sibuk tetap di 30 s sementara resource sepi makin jarang di-poll. Harganya ada di batas atas: dengan 30 menit, perubahan pertama di resource sepi bisa menunggu setengah jam.
Untuk Codex, tabel ini sebenarnya lebih menjadi alasan untuk tidak memakai timer sama sekali. Saat seseorang bertanya "apa yang berubah?", satu putaran dengan cursor tersimpan hanya butuh satu call per resource yang dipantau, dan menjawab tanpa keterlambatan untuk semua yang terjadi sampai saat itu. Jadwal backoff ditujukan untuk script yang membuat Codex, atau apa pun, terus memantau.
Apa yang terjadi saat owner mencabut akses?
Owner menghapus project di 4 h 07 min. Ke-30 cursor sekarang menunjuk ke resource yang tidak bisa dibaca oleh koneksi. Pertanyaannya, berapa lama client terus melakukan polling ke sana:
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 Mengecek grant dengan timer berarti antara menghabiskan call atau membuang ribuan poll gagal. Memperlakukan poll pertama yang ditolak sebagai sinyal "baca ulang grant" hanya memakan satu kegagalan dan satu list_granted_resources tambahan. Setelah itu, buang setiap cursor yang resource-nya sudah tidak ada di daftar. Kalau owner menambahkan project itu lagi nanti, client memulai resource tersebut dari awal.
Di mana schema menghentikan client?
Kasus sisanya, dicek dengan jsonschema (Draft 2020-12) terhadap 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" adalah yang paling sering dicoba pertama kali. Project bukan resource yang punya log. Project adalah grant yang membuat resource terlihat.
Seluruh script memakan 20.2 s waktu nyata di M1, termasuk 40 network call. Waktu CPU user-nya 0.91 s.
Kalau Anda membangunnya sendiri
- Simpan satu log per resource dan biarkan client memegang cursor. Dengan begitu dua pembaca di satu koneksi tidak bisa saling mencuri event, dan server tidak menyimpan state pembaca.
- Beri daftar grant tool sendiri tanpa argumen, dan buat client mulai dari sana. Discovery seharusnya hanya mengembalikan apa yang diberikan owner.
- Batasi ukuran halaman dan jelaskan cara menghabiskannya (panggil lagi dari sequence terakhir sampai halaman tidak penuh).
- Lakukan backoff per resource, bukan global. Resource sibuk tetap cepat, resource sepi jadi murah.
- Pada poll pertama yang ditolak, baca ulang grant dan buang cursor yang sudah hilang, jangan dicoba ulang.
- Tulis kembali cursor setelah Anda menindaklanjuti event-nya, supaya kalau crash event diputar ulang, bukan terlewat.
Cara osFoundry menentukan tools yang didapat setiap koneksi ada di halaman tool scope. Definisi singkat protokolnya ada di entri glosarium MCP.
