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
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/55-an-agent-s-reads-are-rationed-as-writes → main 2 commits fork 1 thread · 1 unresolved
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' \ | ||
| 140 | 140 | |
| 141 | 141 | |
| 142 | 142 | 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: | |
| 147 | 161 | |
| 148 | 162 | json |
| 149 | 163 | { "error": "too_many_requests", "message": "...", "retry_after_ms": 500 } |
src/http.ts +27
| @@ -87,6 +87,33 @@ export const sameOrigin = (request: Request): boolean => { | ||
| 87 | 87 | return origin === null || origin === publicOrigin; |
| 88 | 88 | }; |
| 89 | 89 | |
| 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 | ||
| 90 | 117 | // A request body is read up to a cap and no further, counting the bytes as |
| 91 | 118 | // they arrive. A declared Content-Length is a claim: a chunked request has |
| 92 | 119 | // none, and a lie is free. Undefined means "too large" — refuse it. Past the |
src/mcp.ts +35
| @@ -16,6 +16,8 @@ interface Tool { | ||
| 16 | 16 | readonly name: string; |
| 17 | 17 | readonly description: string; |
| 18 | 18 | 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; | |
| 19 | 21 | readonly run: ( |
| 20 | 22 | db: Database, |
| 21 | 23 | who: Who, |
| @@ -55,6 +57,7 @@ export const tools: readonly Tool[] = [ | ||
| 55 | 57 | { |
| 56 | 58 | name: "post_event", |
| 57 | 59 | description: "Post an event as the signed app or an admin.", |
| 60 | write: true, | |
| 58 | 61 | inputSchema: { |
| 59 | 62 | type: "object", |
| 60 | 63 | properties: { |
| @@ -82,6 +85,38 @@ export const tools: readonly Tool[] = [ | ||
| 82 | 85 | }, |
| 83 | 86 | ]; |
| 84 | 87 | |
| 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 | |
| 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 | ||
| 85 | 120 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
| 86 | 121 | export function rpc( |
| 87 | 122 | 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", () => { | ||
| 103 | 103 | let taken = 0; |
| 104 | 104 | for (let i = 0; i < 25; i++) if (take("parity-take", T0).ok) taken++; |
| 105 | 105 | expect(taken).toBe(20); |
| 106 | expect(PERSON_WRITES).toEqual({ burst: 20, perSecond: 2 }); | |
| 106 | expect(PERSON_WRITES).toEqual({ name: "writes", burst: 20, perSecond: 2 }); | |
| 107 | 107 | // Half a second buys exactly one more. |
| 108 | 108 | expect(take("parity-take", T0 + 500).ok).toBe(true); |
| 109 | 109 | expect(take("parity-take", T0 + 500).ok).toBe(false); |
src/ration.ts +23 −2
| @@ -35,6 +35,16 @@ | ||
| 35 | 35 | */ |
| 36 | 36 | |
| 37 | 37 | 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; | |
| 38 | 48 | /** Tokens in hand when the bucket is full. At least 1. */ |
| 39 | 49 | burst: number; |
| 40 | 50 | /** Tokens added per second. Greater than 0. */ |
| @@ -51,7 +61,7 @@ export type Ration = | ||
| 51 | 61 | * is faster than a form can be filled, so the only caller that ever feels |
| 52 | 62 | * it is a script wearing a person's cookie. |
| 53 | 63 | */ |
| 54 | export const PERSON_WRITES: Limit = { burst: 20, perSecond: 2 }; | |
| 64 | export const PERSON_WRITES: Limit = { name: "writes", burst: 20, perSecond: 2 }; | |
| 55 | 65 | |
| 56 | 66 | /** |
| 57 | 67 | * 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 }; | ||
| 59 | 69 | * five a second is far more than a real app emits and still leaves the |
| 60 | 70 | * timeline readable. An app over this is looping, not working. |
| 61 | 71 | */ |
| 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 }; | |
| 63 | 84 | |
| 64 | 85 | /** At most this many keys are remembered at once. */ |
| 65 | 86 | export const MAX_KEYS = 10_000; |
src/routes.ts +19 −1
| @@ -22,10 +22,12 @@ import { | ||
| 22 | 22 | RELAY_CLIENT, |
| 23 | 23 | staticFile, |
| 24 | 24 | text, |
| 25 | tooManyRequests, | |
| 25 | 26 | } from "./http.ts"; |
| 26 | 27 | import { events } from "./live.ts"; |
| 27 | import { rpc } from "./mcp.ts"; | |
| 28 | import { isWrite, rpc } from "./mcp.ts"; | |
| 28 | 29 | import { home } from "./page.ts"; |
| 30 | import { PERSON_WRITES, type Limit, type Ration } from "./ration.ts"; | |
| 29 | 31 | import type { Who } from "./who.ts"; |
| 30 | 32 | |
| 31 | 33 | export interface Context { |
| @@ -36,6 +38,15 @@ export interface Context { | ||
| 36 | 38 | readonly params: Record<string, string>; |
| 37 | 39 | /** The terminal command, printed verbatim, or `undefined` when there isn't one. */ |
| 38 | 40 | 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; | |
| 39 | 50 | } |
| 40 | 51 | |
| 41 | 52 | export interface Route { |
| @@ -132,6 +143,13 @@ export const routes: readonly Route[] = [ | ||
| 132 | 143 | id: null, |
| 133 | 144 | error: { code: -32700, message: "parse error" }, |
| 134 | 145 | }); |
| 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 | } | |
| 135 | 153 | const { status, body } = rpc(c.db, c.who, message); |
| 136 | 154 | return body === undefined |
| 137 | 155 | ? new Response(null, { status }) |
src/server.test.ts +96
| @@ -341,6 +341,102 @@ test("a refused write says exactly when to come back", async () => { | ||
| 341 | 341 | } |
| 342 | 342 | }); |
| 343 | 343 | |
| 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 | ||
| 344 | 440 | test("stands on the security floor", async () => { |
| 345 | 441 | const response = await fetch(base + "/"); |
| 346 | 442 | expect(response.headers.get("content-security-policy")).toContain( |
src/server.ts +41 −47
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The process the forge runs. `serve` opens the database and listens: |
| 3 | 3 | * 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. | |
| 8 | 12 | */ |
| 9 | 13 | |
| 10 | 14 | import { postRelayEvent } from "./actions.ts"; |
| @@ -17,8 +21,8 @@ import { | ||
| 17 | 21 | title, |
| 18 | 22 | where as envWhere, |
| 19 | 23 | } 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"; | |
| 22 | 26 | import { match } from "./routes.ts"; |
| 23 | 27 | import { who } from "./who.ts"; |
| 24 | 28 | |
| @@ -37,34 +41,6 @@ export interface ServeOptions { | ||
| 37 | 41 | readonly where?: string | undefined; |
| 38 | 42 | } |
| 39 | 43 | |
| 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 | ||
| 68 | 44 | export function serve(options: ServeOptions) { |
| 69 | 45 | const db = openDb(options.dataDir); |
| 70 | 46 | const clock = options.now ?? Date.now; |
| @@ -80,23 +56,40 @@ export function serve(options: ServeOptions) { | ||
| 80 | 56 | async fetch(request, server) { |
| 81 | 57 | const url = new URL(request.url); |
| 82 | 58 | 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 | } | |
| 96 | 59 | const found = match( |
| 97 | 60 | request.method === "HEAD" ? "GET" : request.method, |
| 98 | 61 | url.pathname, |
| 99 | 62 | ); |
| 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 | } | |
| 100 | 93 | if (found === undefined) return text("not found", 404); |
| 101 | 94 | const response = await found.route.handle({ |
| 102 | 95 | request, |
| @@ -105,6 +98,7 @@ export function serve(options: ServeOptions) { | ||
| 105 | 98 | who: me, |
| 106 | 99 | params: found.params, |
| 107 | 100 | ssh, |
| 101 | ration, | |
| 108 | 102 | }); |
| 109 | 103 | // HEAD is GET without the body: a health check sees the app up. |
| 110 | 104 | return request.method === "HEAD" |
Verified on the preview at
a026541, and the issue's symptom is gone: thirtytools/listin a burst all answer 200, forty concurrentpost_eventover MCP give 17/23 200/429, a burnt write bucket leaves reads answering 200,POST /api/eventsafter burning the write bucket over/mcpis 429 (one bucket across both doors, as it should be),GET /api/eventsx60 is never rationed, cross-origin POST is still 403, and the refusal carriesretry-after: 1, the exactretry_after_ms, and the full floor (content-security-policy,x-content-type-options,referrer-policy). I also probed the fail-safe: 25 concurrentbogus/thingand 25 concurrenttools/callof an unknown tool both give 20/5 200/429 — they are charged the write bucket. The classification is right,tooManyRequestsbelongs insrc/http.ts, and the split-key reasoning inration.tsis sound. Three things to fix before it lands.1 — the fail-safe default has no test (fix this first).
isWriteis 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/listis a read,post_eventis a write. Flip eitherreturn trueinisWritetofalseand the suite stays green, while every unrecognizedtools/callstarts 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 tosrc/server.test.ts, inside the frozen-clock block that already exists:Both fail today if the default is flipped, and neither needs a sleep.
2 —
Route.writeis decorative; theREADSbranch at the door is unreachable. No route declareswrite: false, andPOST /api/events'swrite: trueselects the same bucket theundefineddefault already selects, soconst limit = found?.route.write === false ? READS : PERSON_WRITESalways yieldsPERSON_WRITES./mcpnever 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 byAGENTS.md's bar. Either drop both and let the door readPERSON_WRITESplainly (the/mcppath 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 onRoutethat nothing can observe.3 — the bucket key is chosen by reference equality on the
Limitobject.take(limit === READS ? \read:${from}\: from, clock(), limit)holds only while exactly twoLimitvalues ever reachContext.ration.APP_EVENTSalready exists in the same module and #8 will want it through this path; the moment it arrives it lands on the barefromkey and runs a 100/5 cadence against the verytatPERSON_WRITESis using — the corruption the comment two lines above warns about. Make the key come from the limit rather than from===: giveLimitaname("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. Andresources/list/resources/readsit inREAD_METHODSwhilerpcstill answers-32601for 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 beforerpcruns, so a refused write never reaches an action;readBody's cap is still the only thing between the process and a large body;GET/HEADstay free. Fix 1, then 2 and 3, and this lands.