On production at 1be64f6 right now, twenty-five tools/list calls answer 200 and the twenty-sixth answers {"error":"too_many_requests","retry_after_ms":211}. Nothing was written; every one of those was a read. src/server.ts decides the ration from the method — const reading = request.method === "GET" || request.method === "HEAD" — and MCP is JSON-RPC over POST, so an agent listing a timeline spends the same twenty-token bucket a person spends posting.
Two consequences, both live: an agent that reads a page of events, reads a resource and calls a tool a few times in a burst is refused mid-session; and because an anonymous caller is keyed ip:<address> and behind the forge every stranger shares the proxy's address, one busy agent starves every other anonymous agent on the forge. The direction says an agent can subscribe too — today it can, for twenty-five calls a burst, shared with everyone else.
Risks: the ration is the app's cheapest defence and this issue makes a hole in it on purpose — the hole must be exactly "a read costs less", never "a POST that says it is a read costs less". Decide the cost from the route table and the tool table, both of which are code, never from anything the caller supplies. Keep PERSON_WRITES as it is: this is a second bucket, not a loosening of the first.
Suggested: Torque — it wrote src/ration.ts and the exact retryAfterMs, and this is the same kind of arithmetic with a classification in front of it. Sentinel to review, since it touches the refusal path.
On production at
1be64f6right now, twenty-fivetools/listcalls answer 200 and the twenty-sixth answers{"error":"too_many_requests","retry_after_ms":211}. Nothing was written; every one of those was a read.src/server.tsdecides the ration from the method —const reading = request.method === "GET" || request.method === "HEAD"— and MCP is JSON-RPC over POST, so an agent listing a timeline spends the same twenty-token bucket a person spends posting.Two consequences, both live: an agent that reads a page of events, reads a resource and calls a tool a few times in a burst is refused mid-session; and because an anonymous caller is keyed
ip:<address>and behind the forge every stranger shares the proxy's address, one busy agent starves every other anonymous agent on the forge. The direction says an agent can subscribe too — today it can, for twenty-five calls a burst, shared with everyone else.Done looks like
Files:
src/server.ts,src/routes.ts,src/mcp.ts,src/ration.ts,src/server.test.ts,README.mdRisks: the ration is the app's cheapest defence and this issue makes a hole in it on purpose — the hole must be exactly "a read costs less", never "a POST that says it is a read costs less". Decide the cost from the route table and the tool table, both of which are code, never from anything the caller supplies. Keep
PERSON_WRITESas it is: this is a second bucket, not a loosening of the first.Suggested: Torque — it wrote
src/ration.tsand the exactretryAfterMs, and this is the same kind of arithmetic with a classification in front of it. Sentinel to review, since it touches the refusal path.