This is everything osFoundry's MCP server offers a client that wants to know when something changes. Run from this Mac without a 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
Three ways to push are closed. initialize declares tools and nothing else, with listChanged: false, so not even the tool list will announce a change. resources/subscribe is "Method not found". A GET on the MCP URL, where a Streamable HTTP server would open a server-to-client stream, returns 405. What's left is one read-only tool:
{"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"]}]}
"One granted" resource, "resume from an event sequence". The rest of this post is about those words, with the client side rebuilt in Python. No codex binary is installed here, so the script plays the client. osFoundry's server code isn't public: Parts 1 and 2 are the live server, Part 3 is a rebuild whose response shapes and activity rates are its own.
Why poll instead of push, for Codex in particular?
Codex CLI isn't a daemon. It runs when you run it, does a turn, and exits or waits for you. A push channel would need something listening between your sessions, and on a laptop that's often nothing. A sequence cursor fits that life: the client holds one integer per resource, comes back whenever it likes, and asks for what happened after it.
The milliseconds above are measured from Japan on an Apple M1, so they say more about the route than the server. The rate-limit header matters more. Unauthenticated responses carry X-RateLimit-Limit: 2000 with a reset about 61 s ahead. I didn't check whether a signed-in client gets the same budget, but even polling 30 resources every 30 s is 60 requests a minute. Server load isn't what limits a Codex watcher. Its own turns are: each poll is a tool call the model has to make and read.
How does a client find what it may poll?
Two calls, and the schemas say a lot about the 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 takes no arguments at all, and the schema closes the door on adding one:
list_granted_resources no arguments ok
list_granted_resources project_id passed REJECT additionalProperties
It's the server telling the client what it has, not the client asking for something. initialize's instructions start with "Use list_granted_resources first." The answer is the projects added to the connection and any individually granted resources. In the web client (ExternalAssistantsPanel.tsx) those are the Projects section and "Older individual access".
list_project_resources needs a resource_type. There's no "everything in this project" listing, so a full inventory is one call per type, nine types, and more when a type has over 50 items (limit maximum 50, paged with offset). Its description adds the one exception to project access: "Ordinary Message channels require a separate invitation."
graph_session is pollable but not listable. Graphs live on the owner's desktop and come in through list_graph_sessions, and only after the owner shares them from the desktop app.
Why is the cursor the client's and not the server's?
osFoundry has both kinds. wait_for_messages, on the agent network, holds a cursor for you: "Without after_sequence it resumes where your last call stopped", and it long-polls with timeout_s up to 50. poll_resource_events has neither. timeout_s is rejected, and its description only says "resume from", so the client brings the number:
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
A server-held cursor is convenient for one listener. It breaks when two clients share a connection, which is exactly what happens when you open Codex in two terminals with the same login. The rebuild runs 600 channel events past two terminals that take turns polling at random:
two terminals, client cursor: A saw 600/600, B saw 600/600
two terminals, server cursor: A saw 305/600, B saw 295/600
With a server-held cursor per connection, each terminal advances it for the other, and each sees about half. With after_sequence in the request, both see everything, and the server keeps no per-reader state at all. For a resource log that several tools may read independently, that's the right owner for the cursor.
The schema doesn't make after_sequence required, and it doesn't say what omitting it means. Don't rely on it. Store the number and always send it.
How many calls does a full page cost?
limit stops at 100. After a bulk import, a note can have hundreds of events waiting. Draining means calling again from the last sequence until a page comes back short:
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
Ask for 100. The response size in the rebuild is 17 tokens for an empty page and 41 for one event, counted with cl100k_base. Those are the rebuild's shapes, not osFoundry's, but the order of magnitude makes the point: an empty poll is cheap to read, so the cost of polling is mostly the number of turns.
How often should Codex poll 30 resources?
The rebuild gives a project 30 resources of the nine listable types with skewed activity: 3 busy ones (an event a minute), 7 warm (one every 10 minutes), 20 cold (one every 2 hours). Those rates are an assumption. It runs an 8-hour day over five seeds, on a simulated clock:
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
A flat 30 s is fast and 95% wasted: 3,600 calls an hour, almost all empty. A flat 5 minutes is cheap and slow everywhere, including on the busy channel. Per-resource backoff, which doubles a resource's interval after each empty poll and resets it on the first event, gets close to the cheap schedule's call count with a median delay near the fast one. That's because the busy resources stay at 30 s and the cold ones drift out. The cap is the price: with 30 minutes, a cold resource's first change can wait half an hour.
For Codex this table mostly argues against running a timer at all. When a person asks "what changed?", one pass with stored cursors costs one call per watched resource and answers with zero delay for everything up to that moment. The backoff schedule is for a script that keeps Codex, or anything else, watching.
What happens when the owner takes access away?
The owner removes the project at 4 h 07 min. All 30 cursors now point at resources the connection can't read. The question is how long the client keeps polling them:
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
Checking grants on a timer either costs calls or burns thousands of failed polls. Treating the first refused poll as "re-read the grants" costs one failure and one extra list_granted_resources. Then drop every cursor whose resource isn't listed anymore. If the owner adds the project back later, the client starts those resources fresh.
Where do the schemas stop a client?
The remaining cases, checked with jsonschema (Draft 2020-12) against the live inputSchemas:
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" is the one people try first. A project isn't a resource with a log. It's the grant that makes resources visible.
The whole script took 20.2 s wall-clock on the M1, 40 network calls included. User CPU time was 0.91 s.
If you wire this up yourself
- Keep one log per resource and let the client hold the cursor. Two readers on one connection then can't steal each other's events, and the server stores no reader state.
- Give the grant list its own no-argument tool, and make the client start there. Discovery should only return what the owner granted.
- Cap the page size and say how to drain (call again from the last sequence until a short page).
- Back off per resource, not globally. Busy resources stay fast, cold ones get cheap.
- On the first refused poll, re-read the grants and drop the cursors that are gone instead of retrying them.
- Write the cursor back after you've acted on the events, so a crash replays rather than skips.
How osFoundry decides which tools each connection gets is on the tool scope page. The protocol's short definition is in the MCP glossary entry.
Top comments (0)