From 27fb8cc8d7c6d72bd358c5a0a1cc00da86df789c Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Mon, 6 Jul 2026 19:18:30 -0600 Subject: [PATCH 1/3] Document MCP per-client rate limiting and the durable quota hook (harper#1633) Companion to HarperFast/harper#1633 (fixes HarperFast/harper#1610): rateLimit.perClientPerSecond/perClientBurst/identityHeader and the mcp..quota.* hook with the counter-table example. Co-Authored-By: Claude Fable 5 --- reference/mcp/configuration.md | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index 75df3bee..50b7f4cd 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -110,6 +110,84 @@ Default: `100` (operations) / `200` (application) Sustained per-session rate across **all** tools combined. Protects a worker from a single session that spreads its calls across many distinct tools (and so would otherwise dodge `perTool*`). +### `mcp..rateLimit.perClientPerSecond` + +Type: `number` (minimum 0) + +Default: `0` (disabled) + +Sustained `tools/call` rate keyed on **client identity** rather than session (5.1.18+). Session-scoped buckets can be evaded by an anonymous client that cycles sessions (`initialize` → call → drop → repeat); the client bucket survives that loop. Identity is the client socket IP by default, or the value derived from [`identityHeader`](#mcpprofileratelimitidentityheader). Denials surface like other rate-limit hits: an `isError` tool result with `kind: 'rate_limited'`, `scope: 'per_client'`. + +Like the other buckets, state is in-memory per worker — it does not survive a restart and is not shared across workers. For durable quotas, see [`mcp..quota.*`](#mcpprofilequota). + +### `mcp..rateLimit.perClientBurst` + +Type: `number` (minimum 0) + +Default: the `perClientPerSecond` value + +Burst capacity of the per-client bucket. Defaults to the sustained rate so enabling the limit is a one-key change. + +### `mcp..rateLimit.identityHeader` + +Type: `string` + +Default: unset (client identity = socket IP) + +Name of a trusted header whose first (client-most) value supplies client identity — for deployments behind a reverse proxy, where every socket IP is the proxy's. Typically `x-forwarded-for`. + +**Only set this when the fronting proxy strips or replaces the header on untrusted traffic.** A client-controlled identity header lets callers mint fresh identities per request and bypass per-client limits entirely; Harper logs a startup warning when this key is configured. + +## `mcp..quota.*` + +An operator-pluggable **durable** quota hook for `tools/call` (5.1.18+). The in-memory buckets above bound instantaneous rates but reset on restart and are per-worker — insufficient as a cost control for a public, unauthenticated, cost-bearing tool (an LLM-backed `answer`, say). This hook delegates the policy to your code, where it can be backed by a table: + +```yaml +mcp: + application: + quota: + resource: McpQuota +``` + +```javascript +// resources.js +const DAILY_LIMIT = 100; +export class McpQuota extends tables.QuotaCounter { + static async allowMcpCall({ identity, tool, user, profile, sessionId }) { + const id = identity ?? 'unknown'; + const existing = await McpQuota.get(id); + const used = (existing?.used ?? 0) + 1; + await McpQuota.put({ id, used }); + if (used > DAILY_LIMIT) { + return { allowed: false, message: 'daily quota reached', retryAfterSeconds: 3600 }; + } + return true; + } +} +``` + +### `mcp..quota.resource` + +Type: `string` + +Default: unset (no durable quota) + +Path of an exported Resource whose static quota method Harper calls before each admitted `tools/call`. Dispatch uses the live registry class, so an exported subclass (and its policy) wins on reload. + +### `mcp..quota.method` + +Type: `string` + +Default: `allowMcpCall` + +Name of the static method to call. It receives `{ identity, tool, user, profile, sessionId }` (`identity` may be `undefined` when no socket IP or header value is available) and returns `true` to allow or `{ allowed: false, message?, retryAfterSeconds? }` to deny. Denials surface as an `isError` tool result with `kind: 'quota_exceeded'` plus the author-supplied `message`/`retryAfterSeconds`. + +Semantics to know: + +- The hook runs **after** the in-memory buckets admit the call, so rate-limited clients cannot spam a table-backed hook. +- **Fail-closed**: a hook that throws — or a configured `resource`/`method` that doesn't resolve — **denies** the call. Cost protection that silently disables itself on a bug is worse than a hard failure. The raw error is written to the server log only; the client sees a sanitized message. +- Harper calls the hook once per attempted tool call; counting strategy (increment on check vs on success) is the hook's business. + ## `mcp.session.*` Settings that apply to MCP session lifecycle on both profiles. From 0ac01aa553f056359205f8babfbde164bfea07d9 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 7 Jul 2026 14:55:05 -0600 Subject: [PATCH 2/3] Add race-safety caveat to the quota-hook docs (harper#1633 fast-follow) Co-Authored-By: Claude Fable 5 --- reference/mcp/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index 50b7f4cd..0c1ceb87 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -187,6 +187,7 @@ Semantics to know: - The hook runs **after** the in-memory buckets admit the call, so rate-limited clients cannot spam a table-backed hook. - **Fail-closed**: a hook that throws — or a configured `resource`/`method` that doesn't resolve — **denies** the call. Cost protection that silently disables itself on a bug is worse than a hard failure. The raw error is written to the server log only; the client sees a sanitized message. - Harper calls the hook once per attempted tool call; counting strategy (increment on check vs on success) is the hook's business. +- **Race-safety is the hook's business too.** The hook can run concurrently for the same identity — within a worker (interleaving across `await` boundaries) and across workers. A naive read-then-write counter like the example above can undercount under concurrency and admit calls past the limit; make the read-modify-write atomic (a transaction that serializes conflicting writers, a compare-and-set retry loop, or a store with native atomic increments) for production use. ## `mcp.session.*` From b9c98adc11ea657f00bb68a6e546b858df5d5dea Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 8 Jul 2026 08:38:11 -0600 Subject: [PATCH 3/3] Document the perClientBurst one-token floor; version refs 5.2.0 (merged to main) kriszyp review: the floor semantics were claimed but not written; and harper#1633 landed on main rather than v5.1, so the feature ships in 5.2.0, not 5.1.18. Co-Authored-By: Claude Fable 5 --- reference/mcp/configuration.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index 0c1ceb87..aefb03ed 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -116,7 +116,7 @@ Type: `number` (minimum 0) Default: `0` (disabled) -Sustained `tools/call` rate keyed on **client identity** rather than session (5.1.18+). Session-scoped buckets can be evaded by an anonymous client that cycles sessions (`initialize` → call → drop → repeat); the client bucket survives that loop. Identity is the client socket IP by default, or the value derived from [`identityHeader`](#mcpprofileratelimitidentityheader). Denials surface like other rate-limit hits: an `isError` tool result with `kind: 'rate_limited'`, `scope: 'per_client'`. +Sustained `tools/call` rate keyed on **client identity** rather than session (5.2.0+). Session-scoped buckets can be evaded by an anonymous client that cycles sessions (`initialize` → call → drop → repeat); the client bucket survives that loop. Identity is the client socket IP by default, or the value derived from [`identityHeader`](#mcpprofileratelimitidentityheader). Denials surface like other rate-limit hits: an `isError` tool result with `kind: 'rate_limited'`, `scope: 'per_client'`. Like the other buckets, state is in-memory per worker — it does not survive a restart and is not shared across workers. For durable quotas, see [`mcp..quota.*`](#mcpprofilequota). @@ -124,9 +124,9 @@ Like the other buckets, state is in-memory per worker — it does not survive a Type: `number` (minimum 0) -Default: the `perClientPerSecond` value +Default: the `perClientPerSecond` value, floored at `1` -Burst capacity of the per-client bucket. Defaults to the sustained rate so enabling the limit is a one-key change. +Burst capacity of the per-client bucket. Defaults to the sustained rate so enabling the limit is a one-key change — floored at 1 whole token: a fractional `perClientPerSecond` (e.g. `0.1` for "6 per minute") still yields `perClientBurst: 1` by default, since a bucket capped below one token could never admit a call. ### `mcp..rateLimit.identityHeader` @@ -140,7 +140,7 @@ Name of a trusted header whose first (client-most) value supplies client identit ## `mcp..quota.*` -An operator-pluggable **durable** quota hook for `tools/call` (5.1.18+). The in-memory buckets above bound instantaneous rates but reset on restart and are per-worker — insufficient as a cost control for a public, unauthenticated, cost-bearing tool (an LLM-backed `answer`, say). This hook delegates the policy to your code, where it can be backed by a table: +An operator-pluggable **durable** quota hook for `tools/call` (5.2.0+). The in-memory buckets above bound instantaneous rates but reset on restart and are per-worker — insufficient as a cost control for a public, unauthenticated, cost-bearing tool (an LLM-backed `answer`, say). This hook delegates the policy to your code, where it can be backed by a table: ```yaml mcp: