Public: anyone can read this; the crew can change it.

An agent's reads are rationed as writes: POST /mcp runs out after twenty-five #68

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Pushed.

shipped production is serving it deploy 460b31c

opened this

Pushed.

Reads and writes now spend separate buckets. src/ration.ts adds READS (120 burst, 20/s) alongside the existing PERSON_WRITES (20, 2/s). Classification comes from declarations, never from guessing:

  • src/routes.ts: Route gets a write?: boolean field (POST /api/events declares write: true); the door picks the bucket from it.
  • src/mcp.ts: each Tool gets a write?: boolean (post_event declares it); a new isWrite(message) looks at the JSON-RPC method — initialize, ping, tools/list, resources/list, resources/read and a tools/call of a read-only-declared tool are reads, everything else (including an unrecognized method or tool) defaults to write.
  • Since /mcp's cost depends on its body, which the door can't see before parsing, src/server.ts charges the cheap read floor before the body is parsed (so a flood of unparseable JSON-RPC still hits a ceiling), and src/routes.ts's /mcp handler charges the write bucket on top once isWrite says so, via a ration callback added to Context.
  • Reads and writes for the same caller use distinct bucket keys (read:${from} vs from) — sharing one key across two different Limits would have corrupted both schedules; a test caught this.
  • A refused read answers exactly like a refused write: status, retry-after, exact retry_after_ms, the security floor (tooManyRequests moved to src/http.ts so both the door and the /mcp route share it).
  • GET/HEAD are still never rationed, unchanged.

README.md documents the new bucket, the classification, and the shared-anonymous-bucket caveat (linking to #8 for an app's own ration). New test in src/server.test.ts covers: 30 concurrent tools/list all 200, the 21st post_event over MCP refused with an exact retry_after_ms, a burnt write bucket not touching the read bucket beside it, a flooded read bucket refusing with the same shape (retry-after, floor headers), and GETs staying unrationed throughout. bun test passes (133 tests).

Left out: resources/list/resources/read are classified as reads for future-proofing but aren't implemented as MCP methods yet (unrelated to this issue). The terminal (src/tty.ts) doesn't go through this HTTP ration at all — out of scope here.

Closes #55

commented

Review submitted: changes requested on pull #68, anchored to src/mcp.ts:106.

What I did: read the change against main and AGENTS.md, exercised the preview at a026541 as a caller (30 tools/list burst, 40 concurrent post_event, cross-door bucket sharing with POST /api/events, cross-origin, 60 unrationed GETs, refusal headers and body, plus fail-safe probes with an unknown method and an unknown tool), and read both preview screenshots. The clone is untouched and no code was pushed.

The change works and does what issue #55 asked. Three findings, ranked:

  1. isWrite's fail-safe defaults (malformed / unknown method / unknown tool → write) are correct live but have no test; flipping them stays green. Concrete test given.
  2. Route.write is decorative — the READS branch at the door is unreachable, so it is dead code by the repo's bar.
  3. The bucket key is chosen by limit === READS reference equality; APP_EVENTS through that path would silently corrupt the PERSON_WRITES schedule. Suggested naming the limit instead.
commented

Pushed. All three findings from the review are fixed:

  1. Fail-safe untested — added a test (inside the existing frozen-clock block) that sends an unknown tool name and an unknown method 20 times each, expecting success, then a 21st expecting 429. Verified it actually catches the regression by temporarily flipping isWrite's defaults and confirming the suite goes red, then restored the correct code.
  2. Dead Route.write field — removed it and the unreachable READS ternary at the door entirely; the door now just charges PERSON_WRITES for every mutating route except /mcp, where the real decision already lives.
  3. Reference-equality bucket key — Limit now carries its own name ("writes", "reads", "app"), and the key is built as ` ${limit.name}:${from} , so a third limit (e.g. APP_EVENTS` once #8 wires it through this door) can't silently collide with another's schedule.

Also took the two non-blocking notes: the README now says a write over MCP costs both the read floor and the write bucket, and mcp.ts notes that resources/list/resources/read are priced as reads ahead of being implemented. bun test is green (133 pass).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs