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

scout/relay:scout/55-an-agent-s-reads-are-rationed-as-writes → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/55-an-agent-s-reads-are-rationed-as-writes was merged into main as 40cca3d.

Review

Sign in to review this change.
8 files changed +260 −55
README.md +18 −4
@@ -140,10 +140,24 @@ curl -X POST https://<app>/api/events -H 'content-type: application/json' \
140140 ```
141141
142142 Writes are rationed per person, twenty in hand and two a second (per
143 address for a stranger, since a person is their own bucket). Over it, a
144 write is refused with `429` and a `retry-after` in whole seconds; the JSON
145 doors (`/api/*` and `/mcp`) also carry the exact wait, so a script can
146 sleep precisely rather than a rounded second too long:
143 address for a stranger, since a person is their own bucket — and behind
144 the forge every anonymous caller shares its one address, so a busy
145 anonymous agent shares its bucket with every other stranger; one that
146 wants its own signs in, or, once apps can post as themselves (#8), posts
147 as an app instead). A read is rationed apart from a write, on its own
148 larger bucket — 120 in hand and 20 a second, since an agent paging a
149 timeline in a burst is not a person typing a form. `/mcp` costs whichever
150 its JSON-RPC method does: `initialize`, `ping`, `tools/list` and a
151 `tools/call` of a read-only tool are reads; `post_event` and any other
152 writing tool are writes, decided from the tool's own declaration, never
153 from its arguments. A write over MCP costs both buckets — the read floor
154 `/mcp` always pays before its body is parsed, and the write bucket on top
155 once the method is known — so an agent that has burnt its read bucket
156 cannot post even with write tokens still in hand. Over either ceiling, a
157 call is refused with `429` and
158 a `retry-after` in whole seconds; the JSON doors (`/api/*` and `/mcp`)
159 also carry the exact wait, so a script can sleep precisely rather than a
160 rounded second too long:
147161
148162 ```json
149163 { "error": "too_many_requests", "message": "...", "retry_after_ms": 500 }
src/http.ts +27
@@ -87,6 +87,33 @@ export const sameOrigin = (request: Request): boolean => {
8787 return origin === null || origin === publicOrigin;
8888 };
8989
90 /**
91 * A refused request says when to come back. `retry-after` is whole seconds
92 * rounded up from the exact milliseconds — rounding down would name a
93 * moment that is still refused, and a bare 0 invites an instant retry that
94 * must fail, so the minimum on the wire is 1.
95 *
96 * The body follows the door it came to: a JSON door carries the exact
97 * `retry_after_ms` so a script can sleep precisely rather than a rounded
98 * second too long; anything else gets the sentence a browser would show.
99 */
100 export function tooManyRequests(pathname: string, retryAfterMs: number): Response {
101 const seconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
102 const headers = { "retry-after": String(seconds) };
103 const message = `too many requests: retry in ${retryAfterMs}ms`;
104 return pathname === "/mcp" || pathname.startsWith("/api/")
105 ? json(
106 {
107 error: "too_many_requests",
108 message,
109 retry_after_ms: retryAfterMs,
110 },
111 429,
112 headers,
113 )
114 : text("too many requests", 429, headers);
115 }
116
90117 // A request body is read up to a cap and no further, counting the bytes as
91118 // they arrive. A declared Content-Length is a claim: a chunked request has
92119 // none, and a lie is free. Undefined means "too large" — refuse it. Past the
src/mcp.ts +35
@@ -16,6 +16,8 @@ interface Tool {
1616 readonly name: string;
1717 readonly description: string;
1818 readonly inputSchema: Record<string, unknown>;
19 /** True for a tool that writes. Absent (read) is the default a caller pays for. */
20 readonly write?: boolean;
1921 readonly run: (
2022 db: Database,
2123 who: Who,
@@ -55,6 +57,7 @@ export const tools: readonly Tool[] = [
5557 {
5658 name: "post_event",
5759 description: "Post an event as the signed app or an admin.",
60 write: true,
5861 inputSchema: {
5962 type: "object",
6063 properties: {
@@ -82,6 +85,38 @@ export const tools: readonly Tool[] = [
8285 },
8386 ];
8487
88 // Methods that never write, whatever they're called as (a request or a
89 // notification — a notification is rationed the same as its method).
90 // `resources/list` and `resources/read` aren't implemented below (`rpc`
91 // answers -32601 for both today), but they're read methods by the MCP
92 // spec, so they're priced as reads now rather than as writes the day
93 // they're added.
94 const READ_METHODS = new Set([
95 "initialize",
96 "ping",
97 "tools/list",
98 "resources/list",
99 "resources/read",
100 ]);
101
102 /**
103 * Whether running this message would write — decided from its method and,
104 * for `tools/call`, from the named tool's own `write` declaration, never
105 * from its arguments. Anything not read here — a malformed message, an
106 * unknown method, an unknown tool — defaults to a write: the classification
outdated

Verified on the preview at a026541, and the issue's symptom is gone: thirty tools/list in a burst all answer 200, forty concurrent post_event over MCP give 17/23 200/429, a burnt write bucket leaves reads answering 200, POST /api/events after burning the write bucket over /mcp is 429 (one bucket across both doors, as it should be), GET /api/events x60 is never rationed, cross-origin POST is still 403, and the refusal carries retry-after: 1, the exact retry_after_ms, and the full floor (content-security-policy, x-content-type-options, referrer-policy). I also probed the fail-safe: 25 concurrent bogus/thing and 25 concurrent tools/call of an unknown tool both give 20/5 200/429 — they are charged the write bucket. The classification is right, tooManyRequests belongs in src/http.ts, and the split-key reasoning in ration.ts is sound. Three things to fix before it lands.

1 — the fail-safe default has no test (fix this first). isWrite is exported and its doc comment names the load-bearing security property: a malformed message, an unknown method and an unknown tool all default to a write. The new test exercises exactly two branches — tools/list is a read, post_event is a write. Flip either return true in isWrite to false and the suite stays green, while every unrecognized tools/call starts costing a read. That is precisely the hole the issue's Risks section said must not open, and it is the one branch nothing guards. Add to src/server.test.ts, inside the frozen-clock block that already exists:

// An unknown tool and an unknown method are writes: the classification
// only ever narrows what costs more.
const stranger = as("u-mcp-unknown", "stranger", "admin");
for (let i = 0; i < 20; i++)
  expect((await call(stranger, "tools/call", { name: "nope" })).status).toBe(200);
expect((await call(stranger, "tools/call", { name: "nope" })).status).toBe(429);

const odd = as("u-mcp-odd", "odd", "admin");
for (let i = 0; i < 20; i++)
  expect((await call(odd, "bogus/thing")).status).toBe(200);
expect((await call(odd, "bogus/thing")).status).toBe(429);

Both fail today if the default is flipped, and neither needs a sleep.

2 — Route.write is decorative; the READS branch at the door is unreachable. No route declares write: false, and POST /api/events's write: true selects the same bucket the undefined default already selects, so const limit = found?.route.write === false ? READS : PERSON_WRITES always yields PERSON_WRITES. /mcp never reaches it either — the door branches on the pathname first. Delete the field and the ternary and nothing in the app changes and no test goes red, which is the definition of dead code by AGENTS.md's bar. Either drop both and let the door read PERSON_WRITES plainly (the /mcp path is where the real decision lives, and that is fine), or make it load-bearing by giving a POST route that genuinely only reads and a test that shows it on the cheap bucket. Do not leave a new public field on Route that nothing can observe.

3 — the bucket key is chosen by reference equality on the Limit object. take(limit === READS ? \read:${from}\ : from, clock(), limit) holds only while exactly two Limit values ever reach Context.ration. APP_EVENTS already exists in the same module and #8 will want it through this path; the moment it arrives it lands on the bare from key and runs a 100/5 cadence against the very tat PERSON_WRITES is using — the corruption the comment two lines above warns about. Make the key come from the limit rather than from ===: give Limit a name ("writes", "reads", "app") and build ` ${limit.name}:${from} `. One field, one template string, and the trap closes for every limit that ever exists.

Two smaller notes, no need to block on them: the README says /mcp "costs whichever its JSON-RPC method does", but a write there actually costs a read and a write (the door's floor plus the route's charge) — worth the half-sentence, since an agent that has burnt its read bucket cannot post even with twenty write tokens in hand, and that will surprise someone. And resources/list/resources/read sit in READ_METHODS while rpc still answers -32601 for them; harmless and cheap today, but they are the one pair in that set with nothing behind them.

Everything else reads right: identity still comes from the verified signed headers and the key is user:<id> or the socket address, never a header the caller wrote; the same-origin refusal still precedes the ration; the write is charged before rpc runs, so a refused write never reaches an action; readBody's cap is still the only thing between the process and a large body; GET/HEAD stay free. Fix 1, then 2 and 3, and this lands.

107 * only ever narrows what costs more, never a door `post_event` walks
108 * through.
109 */
110 export function isWrite(message: unknown): boolean {
111 const m = fields(message);
112 if (typeof m.method !== "string") return true;
113 if (m.method === "tools/call") {
114 const tool = tools.find((t) => t.name === fields(m.params).name);
115 return tool === undefined || tool.write === true;
116 }
117 return !READ_METHODS.has(m.method);
118 }
119
85120 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
86121 export function rpc(
87122 db: Database,
src/ration.test.ts +1 −1
@@ -103,7 +103,7 @@ test("a person's ration is twenty at once, then two a second", () => {
103103 let taken = 0;
104104 for (let i = 0; i < 25; i++) if (take("parity-take", T0).ok) taken++;
105105 expect(taken).toBe(20);
106 expect(PERSON_WRITES).toEqual({ burst: 20, perSecond: 2 });
106 expect(PERSON_WRITES).toEqual({ name: "writes", burst: 20, perSecond: 2 });
107107 // Half a second buys exactly one more.
108108 expect(take("parity-take", T0 + 500).ok).toBe(true);
109109 expect(take("parity-take", T0 + 500).ok).toBe(false);
src/ration.ts +23 −2
@@ -35,6 +35,16 @@
3535 */
3636
3737 export type Limit = {
38 /**
39 * Names this limit's own bucket, distinct from every other limit's. A
40 * caller who is rationed under more than one limit (a person's reads and
41 * writes, say) must land on a different key per limit — two limits
42 * sharing a key would spend one's schedule at the other's cadence — so
43 * the key is built from this, never from comparing `Limit` objects by
44 * reference, which breaks the moment a new one is passed through the
45 * same door.
46 */
47 name: string;
3848 /** Tokens in hand when the bucket is full. At least 1. */
3949 burst: number;
4050 /** Tokens added per second. Greater than 0. */
@@ -51,7 +61,7 @@ export type Ration =
5161 * is faster than a form can be filled, so the only caller that ever feels
5262 * it is a script wearing a person's cookie.
5363 */
54 export const PERSON_WRITES: Limit = { burst: 20, perSecond: 2 };
64 export const PERSON_WRITES: Limit = { name: "writes", burst: 20, perSecond: 2 };
5565
5666 /**
5767 * An app posting events. A deploy or a batch job fires a hundred at once
@@ -59,7 +69,18 @@ export const PERSON_WRITES: Limit = { burst: 20, perSecond: 2 };
5969 * five a second is far more than a real app emits and still leaves the
6070 * timeline readable. An app over this is looping, not working.
6171 */
62 export const APP_EVENTS: Limit = { burst: 100, perSecond: 5 };
72 export const APP_EVENTS: Limit = { name: "app", burst: 100, perSecond: 5 };
73
74 /**
75 * A read, however it arrives — including one wearing a POST, since MCP is
76 * JSON-RPC over POST and has no verb of its own to read `PERSON_WRITES`
77 * from. An agent paging a timeline in a burst is not a person typing a
78 * form: 120 in hand is several pages at once, and 20 a second is a session
79 * working through a burst, not a loop. Still a ceiling, and a cheaper one
80 * than a write: `PERSON_WRITES` is what an actual write costs, even one
81 * that arrives over MCP.
82 */
83 export const READS: Limit = { name: "reads", burst: 120, perSecond: 20 };
6384
6485 /** At most this many keys are remembered at once. */
6586 export const MAX_KEYS = 10_000;
src/routes.ts +19 −1
@@ -22,10 +22,12 @@ import {
2222 RELAY_CLIENT,
2323 staticFile,
2424 text,
25 tooManyRequests,
2526 } from "./http.ts";
2627 import { events } from "./live.ts";
27 import { rpc } from "./mcp.ts";
28 import { isWrite, rpc } from "./mcp.ts";
2829 import { home } from "./page.ts";
30 import { PERSON_WRITES, type Limit, type Ration } from "./ration.ts";
2931 import type { Who } from "./who.ts";
3032
3133 export interface Context {
@@ -36,6 +38,15 @@ export interface Context {
3638 readonly params: Record<string, string>;
3739 /** The terminal command, printed verbatim, or `undefined` when there isn't one. */
3840 readonly ssh: string | undefined;
41 /**
42 * Spends one token of the caller's ration under `limit`; `undefined` for
43 * a request the door never rations (GET, HEAD). Every other mutating
44 * route is a plain write, already charged at the door before its handler
45 * runs — only `/mcp` looks inside its own body to know what it costs, so
46 * only `/mcp` charges itself with this, on top of the read the door
47 * already spent to get here.
48 */
49 readonly ration: ((limit: Limit) => Ration) | undefined;
3950 }
4051
4152 export interface Route {
@@ -132,6 +143,13 @@ export const routes: readonly Route[] = [
132143 id: null,
133144 error: { code: -32700, message: "parse error" },
134145 });
146 // The door already spent a read to get here; a call that writes
147 // spends the costlier bucket too, decided from the tool's own
148 // declaration and never from its arguments.
149 if (isWrite(message) && c.ration !== undefined) {
150 const spent = c.ration(PERSON_WRITES);
151 if (!spent.ok) return tooManyRequests("/mcp", spent.retryAfterMs);
152 }
135153 const { status, body } = rpc(c.db, c.who, message);
136154 return body === undefined
137155 ? new Response(null, { status })
src/server.test.ts +96
@@ -341,6 +341,102 @@ test("a refused write says exactly when to come back", async () => {
341341 }
342342 });
343343
344 test("an MCP read is rationed apart from a write, on its own larger bucket", async () => {
345 // Its own clock, so the wait is arithmetic and not a sleep.
346 const now = 1_700_000_000_000;
347 const clocked = serve({
348 port: 0,
349 dataDir: mkdtempSync(join(tmpdir(), "app-")),
350 identityKey: KEY,
351 now: () => now,
352 });
353 try {
354 const at = `http://${clocked.hostname}:${clocked.port}`;
355 const call = (who: Record<string, string>, method: string, params: unknown = {}) =>
356 fetch(at + "/mcp", {
357 method: "POST",
358 ...jsonBody({ jsonrpc: "2.0", id: 1, method, params }),
359 headers: { "content-type": "application/json", ...who },
360 });
361
362 // Thirty `tools/list` on the same millisecond all answer 200 — a burst
363 // no person typing could produce, exactly what an agent paging a
364 // timeline sends, and well inside the 120 a read has in hand.
365 const reader = as("u-mcp-reads", "reader", "admin");
366 const listed = await Promise.all(
367 Array.from({ length: 30 }, () => call(reader, "tools/list")),
368 );
369 expect(listed.every((r) => r.status === 200)).toBe(true);
370
371 // The write bucket still refuses a 21st `post_event`, with the exact
372 // wait a plain POST to /api/events would give.
373 const writer = as("u-mcp-writes", "writer", "admin");
374 const post = () =>
375 call(writer, "tools/call", {
376 name: "post_event",
377 arguments: { ...event, source: "mcp" },
378 });
379 for (let i = 0; i < 20; i++) {
380 const r = await post();
381 expect(r.status).toBe(200);
382 const body = (await r.json()) as { result: { isError?: boolean } };
383 expect(body.result.isError).toBeUndefined();
384 }
385 const refusedWrite = await post();
386 expect(refusedWrite.status).toBe(429);
387 const writeBody = (await refusedWrite.json()) as { retry_after_ms: number };
388 expect(writeBody.retry_after_ms).toBe(500);
389 expect(refusedWrite.headers.get("retry-after")).toBe("1");
390
391 // An unknown tool and an unknown method are writes: the classification
392 // only ever narrows what costs more.
393 const stranger = as("u-mcp-unknown", "stranger", "admin");
394 for (let i = 0; i < 20; i++)
395 expect(
396 (await call(stranger, "tools/call", { name: "nope" })).status,
397 ).toBe(200);
398 expect((await call(stranger, "tools/call", { name: "nope" })).status).toBe(
399 429,
400 );
401 const odd = as("u-mcp-odd", "odd", "admin");
402 for (let i = 0; i < 20; i++)
403 expect((await call(odd, "bogus/thing")).status).toBe(200);
404 expect((await call(odd, "bogus/thing")).status).toBe(429);
405
406 // The writer's own reads are untouched: a burnt write bucket doesn't
407 // burn the read bucket sitting beside it.
408 expect((await call(writer, "tools/list")).status).toBe(200);
409
410 // Flooding the reader's read bucket past its 120 in hand answers a
411 // refusal shaped exactly like a write's: the exact wait, the floor.
412 const flooded = await Promise.all(
413 Array.from({ length: 130 }, () => call(reader, "tools/list")),
414 );
415 const refusedRead = flooded.find((r) => r.status === 429);
416 expect(refusedRead).toBeDefined();
417 expect(
418 Number(refusedRead!.headers.get("retry-after")),
419 ).toBeGreaterThanOrEqual(1);
420 expect(refusedRead!.headers.get("content-security-policy")).toContain(
421 "default-src 'self'",
422 );
423 const readBody = (await refusedRead!.json()) as {
424 error: string;
425 retry_after_ms: number;
426 };
427 expect(readBody.error).toBe("too_many_requests");
428 expect(readBody.retry_after_ms).toBeGreaterThan(0);
429
430 // A GET is never rationed, whatever either POST bucket looks like.
431 const gets = await Promise.all(
432 Array.from({ length: 50 }, () => fetch(at + "/api/events")),
433 );
434 expect(gets.every((r) => r.status === 200)).toBe(true);
435 } finally {
436 clocked.stop(true);
437 }
438 });
439
344440 test("stands on the security floor", async () => {
345441 const response = await fetch(base + "/");
346442 expect(response.headers.get("content-security-policy")).toContain(
src/server.ts +41 −47
@@ -1,10 +1,14 @@
11 /**
22 * The process the forge runs. `serve` opens the database and listens:
33 * every request gets the floor (`http.ts`), an identity (`who.ts`) and
4 * one route (`routes.ts`), and a write is refused before it is read if it
5 * came from another site or from someone past their ration (`ration.ts`,
6 * one bucket per person or address). Run directly,
7 * it serves what the forge said; a test calls `serve` with its own.
4 * one route (`routes.ts`), and a mutating request is refused before it is
5 * read if it came from another site or from someone past their ration
6 * (`ration.ts`, one bucket per person or address). Every mutating route
7 * but `/mcp` is a plain write, charged here; `/mcp`'s cost depends on the
8 * JSON-RPC method inside, which only the route can see, so the door
9 * charges the read floor and the route charges the rest once it knows. Run
10 * directly, it serves what the forge said; a test calls `serve` with its
11 * own.
812 */
913
1014 import { postRelayEvent } from "./actions.ts";
@@ -17,8 +21,8 @@ import {
1721 title,
1822 where as envWhere,
1923 } from "./env.ts";
20 import { html, json, sameOrigin, text } from "./http.ts";
21 import { PERSON_WRITES, take } from "./ration.ts";
24 import { html, sameOrigin, text, tooManyRequests } from "./http.ts";
25 import { PERSON_WRITES, READS, take, type Limit, type Ration } from "./ration.ts";
2226 import { match } from "./routes.ts";
2327 import { who } from "./who.ts";
2428
@@ -37,34 +41,6 @@ export interface ServeOptions {
3741 readonly where?: string | undefined;
3842 }
3943
40 /**
41 * A refused write says when to come back. `retry-after` is whole seconds
42 * rounded up from the exact milliseconds — rounding down would name a
43 * moment that is still refused, and a bare 0 invites an instant retry that
44 * must fail, so the minimum on the wire is 1.
45 *
46 * The body follows the door it came to: a JSON door carries the exact
47 * `retry_after_ms` so a script can sleep precisely rather than a rounded
48 * second too long; anything else gets the sentence a browser would show.
49 * Both stand on the floor in `src/http.ts`.
50 */
51 function tooManyRequests(pathname: string, retryAfterMs: number): Response {
52 const seconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
53 const headers = { "retry-after": String(seconds) };
54 const message = `too many requests: retry in ${retryAfterMs}ms`;
55 return pathname === "/mcp" || pathname.startsWith("/api/")
56 ? json(
57 {
58 error: "too_many_requests",
59 message,
60 retry_after_ms: retryAfterMs,
61 },
62 429,
63 headers,
64 )
65 : text("too many requests", 429, headers);
66 }
67
6844 export function serve(options: ServeOptions) {
6945 const db = openDb(options.dataDir);
7046 const clock = options.now ?? Date.now;
@@ -80,23 +56,40 @@ export function serve(options: ServeOptions) {
8056 async fetch(request, server) {
8157 const url = new URL(request.url);
8258 const me = who(request, options.identityKey);
83 const reading = request.method === "GET" || request.method === "HEAD";
84 if (!reading) {
85 if (!sameOrigin(request)) return text("forbidden", 403);
86 // Behind the forge every stranger shares its address; a person is
87 // their own bucket.
88 const from =
89 me.role === "anon"
90 ? `ip:${server.requestIP(request)?.address ?? "?"}`
91 : `user:${me.id}`;
92 const ration = take(from, clock(), PERSON_WRITES);
93 if (!ration.ok)
94 return tooManyRequests(url.pathname, ration.retryAfterMs);
95 }
9659 const found = match(
9760 request.method === "HEAD" ? "GET" : request.method,
9861 url.pathname,
9962 );
63 const reading = request.method === "GET" || request.method === "HEAD";
64 let ration: ((limit: Limit) => Ration) | undefined;
65 if (!reading) {
66 if (!sameOrigin(request)) return text("forbidden", 403);
67 // Behind the forge every stranger shares its address; a person, or
68 // an anonymous agent's entire session, is one bucket.
69 const from =
70 me.role === "anon"
71 ? `ip:${server.requestIP(request)?.address ?? "?"}`
72 : `user:${me.id}`;
73 // Two limits, two schedules: charging a read's cadence and a
74 // write's cadence against the very same stored `tat` would corrupt
75 // both, so the key is namespaced by the limit's own name, never by
76 // comparing `Limit` objects — that breaks the moment a third one
77 // (an app's, say) reaches this door.
78 ration = (limit) => take(`${limit.name}:${from}`, clock(), limit);
79 if (url.pathname === "/mcp") {
80 // The verb is always POST; only the parsed body says read or
81 // write, and only the route can see it. What's charged here is
82 // the floor every call to this door pays before its body is even
83 // parsed, so a flood of unparseable JSON-RPC still hits a
84 // ceiling rather than an unlimited free parse.
85 const pre = ration(READS);
86 if (!pre.ok) return tooManyRequests(url.pathname, pre.retryAfterMs);
87 } else {
88 const spent = ration(PERSON_WRITES);
89 if (!spent.ok)
90 return tooManyRequests(url.pathname, spent.retryAfterMs);
91 }
92 }
10093 if (found === undefined) return text("not found", 404);
10194 const response = await found.route.handle({
10295 request,
@@ -105,6 +98,7 @@ export function serve(options: ServeOptions) {
10598 who: me,
10699 params: found.params,
107100 ssh,
101 ration,
108102 });
109103 // HEAD is GET without the body: a health check sees the app up.
110104 return request.method === "HEAD"

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs