From ac059db32649b3e6229af6c30d306b801d0db780 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 8 Jul 2026 17:42:16 -0600 Subject: [PATCH 1/5] Gate the quota-hook example's table off the MCP surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example exported the counter table, which surfaces update_/delete_ verb tools and REST — letting a permitted client reset its own quota. Found by a blind agent evaluation of the harper-mcp skill (skills#69). Co-Authored-By: Claude Fable 5 --- reference/mcp/configuration.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index aefb03ed..f4d6396e 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -153,6 +153,12 @@ mcp: // resources.js const DAILY_LIMIT = 100; export class McpQuota extends tables.QuotaCounter { + // Exporting makes the class config-addressable — but it would also surface + // update_/delete_McpQuota MCP tools and a REST endpoint, letting a + // permitted client reset its own counter. Keep the quota table off the MCP + // surface and restrict its REST permissions. + static exportTypes = { mcp: false }; + static async allowMcpCall({ identity, tool, user, profile, sessionId }) { const id = identity ?? 'unknown'; const existing = await McpQuota.get(id); From bfcbd930f7a72dc3bb078b69b9f583a331fe4063 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 9 Jul 2026 18:53:54 -0600 Subject: [PATCH 2/5] Use the real registration mechanism to gate the quota class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static exportTypes is never read off exported classes (kriszyp) — the wired mechanism is server.resources.set(name, Class, exportTypes), and rest: false is needed for the REST side independently. Runtime-verified on main: McpQuota absent from tools/list, REST 404, quota hook still resolves and denies past the limit. Co-Authored-By: Claude Fable 5 --- reference/mcp/configuration.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index f4d6396e..220101af 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -152,13 +152,7 @@ mcp: ```javascript // resources.js const DAILY_LIMIT = 100; -export class McpQuota extends tables.QuotaCounter { - // Exporting makes the class config-addressable — but it would also surface - // update_/delete_McpQuota MCP tools and a REST endpoint, letting a - // permitted client reset its own counter. Keep the quota table off the MCP - // surface and restrict its REST permissions. - static exportTypes = { mcp: false }; - +class McpQuota extends tables.QuotaCounter { static async allowMcpCall({ identity, tool, user, profile, sessionId }) { const id = identity ?? 'unknown'; const existing = await McpQuota.get(id); @@ -170,6 +164,12 @@ export class McpQuota extends tables.QuotaCounter { return true; } } + +// Register the class so the quota hook can resolve it by name — WITHOUT +// module-exporting it, which would surface update_/delete_McpQuota MCP tools +// and a REST endpoint that let a permitted client reset its own counter. +// exportTypes gates each transport independently (see the HTTP API reference). +server.resources.set('McpQuota', McpQuota, { mcp: false, rest: false }); ``` ### `mcp..quota.resource` From 49104e9901d77b62343764af44602996807fe429 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Mon, 13 Jul 2026 22:22:12 -0600 Subject: [PATCH 3/5] Close all six transports in the quota-gating example The example set { mcp: false, rest: false }, closing only two of the six transports exportTypes gates. Since McpQuota extends QuotaCounter (standard Table CRUD) and unset means exposed, the 'reset your own counter' path the example prevents was still reachable via SSE/WS/GraphQL/MQTT. Set all six (mcp, rest, sse, ws, graphql, mqtt) and reword the comment so it doesn't read as fully closed when it wasn't. Addresses kriszyp's review on #576 (incl. the sse surface his suggestion omitted). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- reference/mcp/configuration.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index 220101af..24b75600 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -166,10 +166,19 @@ class McpQuota extends tables.QuotaCounter { } // Register the class so the quota hook can resolve it by name — WITHOUT -// module-exporting it, which would surface update_/delete_McpQuota MCP tools -// and a REST endpoint that let a permitted client reset its own counter. -// exportTypes gates each transport independently (see the HTTP API reference). -server.resources.set('McpQuota', McpQuota, { mcp: false, rest: false }); +// module-exporting it. An exported QuotaCounter surfaces its inherited CRUD verbs +// (update_/delete_McpQuota MCP tools, plus REST/SSE/WS/GraphQL/MQTT access) that +// let a permitted client reset its own counter. exportTypes gates each transport +// independently and unset means exposed, so close every one — not just the two +// that named the bug (see the HTTP API reference). +server.resources.set('McpQuota', McpQuota, { + mcp: false, + rest: false, + sse: false, + ws: false, + graphql: false, + mqtt: false, +}); ``` ### `mcp..quota.resource` From c20bfac5325c0ba1a5e27149d6de6a81ecf2c4be Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 14 Jul 2026 15:38:30 -0600 Subject: [PATCH 4/5] Reconcile quota.resource prose with the non-exporting example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kriszyp review (docs#576 #2): the prose credited *export* for 'wins on reload', but the hardened example deliberately does not export (it registers via server.resources.set with all transports off). The quota hook actually resolves the resource by name from the live registry each call (checkDurableQuota -> getResources().get(path)), and server.resources.set() populates that same registry, so registration — not export — is what makes it resolvable, and the currently-registered class wins on reload. Prose now says 'registered' and names both registration paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- reference/mcp/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index 24b75600..f7670f94 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -187,7 +187,7 @@ 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. +Path of a **registered** Resource whose static quota method Harper calls before each admitted `tools/call`. Harper resolves the resource by name from the live registry on every call, so the currently-registered class (and its policy) wins on reload. Registration — not export — is what makes it resolvable: register it either by exporting a `@export`/schema Resource, or (as in the example above) with `server.resources.set(name, Resource, exportTypes)`, which keeps it resolvable by the hook while `exportTypes: { …all false }` keeps its inherited CRUD off every transport. ### `mcp..quota.method` From fb3d44909bab229a18d912056857a03d2094a346 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 15 Jul 2026 12:36:14 -0600 Subject: [PATCH 5/5] Document the durable quota as server.setMcpQuotaHandler, not config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to harper#1821, which replaces the mcp..quota.resource/method config (a Resource reference, public-by-default) with a registration function. Rewrite the quota section: drop the removed config keys, and show a registered function backed by an INTERNAL (non-@export) counter table plus a separate exported tool — so nothing exposes update_/delete_ CRUD a client could use to reset its own counter. Resolves Kris's review that the fix belongs in the API. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- reference/mcp/configuration.md | 81 ++++++++++++++-------------------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/reference/mcp/configuration.md b/reference/mcp/configuration.md index f7670f94..3442d251 100644 --- a/reference/mcp/configuration.md +++ b/reference/mcp/configuration.md @@ -118,7 +118,7 @@ Default: `0` (disabled) 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). +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 the [durable quota handler](#durable-quota-handler). ### `mcp..rateLimit.perClientBurst` @@ -138,71 +138,56 @@ Name of a trusted header whose first (client-most) value supplies client identit **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.*` +## Durable quota handler -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: +An operator-pluggable **durable** quota policy 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). A component registers the policy as a **function** with `server.setMcpQuotaHandler`, so the policy is never itself an exposed Resource, and it can be backed by an internal table: -```yaml -mcp: - application: - quota: - resource: McpQuota +```graphql +# schema.graphql — an INTERNAL per-identity counter. No @export, so no client can read or reset it. +type QuotaCounter @table { + id: ID @primaryKey + used: Int +} ``` ```javascript // resources.js const DAILY_LIMIT = 100; -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; + +// The cost-bearing tool clients call (exported — this is the public surface). +export class Answerer extends Resource { + static mcpTools = [{ name: 'answer', description: 'Answer a question', method: 'doAnswer' }]; + async doAnswer(args) { + return { answered: args?.q ?? '' }; } } -// Register the class so the quota hook can resolve it by name — WITHOUT -// module-exporting it. An exported QuotaCounter surfaces its inherited CRUD verbs -// (update_/delete_McpQuota MCP tools, plus REST/SSE/WS/GraphQL/MQTT access) that -// let a permitted client reset its own counter. exportTypes gates each transport -// independently and unset means exposed, so close every one — not just the two -// that named the bug (see the HTTP API reference). -server.resources.set('McpQuota', McpQuota, { - mcp: false, - rest: false, - sse: false, - ws: false, - graphql: false, - mqtt: false, +// Register the durable quota policy as a function, backed by the internal counter table. +server.setMcpQuotaHandler(async ({ identity, tool, user, profile, sessionId }) => { + if (profile !== 'application') return true; // gate per profile in code + const id = identity ?? 'unknown'; + const existing = await tables.QuotaCounter.get(id); + const used = (existing?.used ?? 0) + 1; + await tables.QuotaCounter.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 a **registered** Resource whose static quota method Harper calls before each admitted `tools/call`. Harper resolves the resource by name from the live registry on every call, so the currently-registered class (and its policy) wins on reload. Registration — not export — is what makes it resolvable: register it either by exporting a `@export`/schema Resource, or (as in the example above) with `server.resources.set(name, Resource, exportTypes)`, which keeps it resolvable by the hook while `exportTypes: { …all false }` keeps its inherited CRUD off every transport. - -### `mcp..quota.method` - -Type: `string` +### `server.setMcpQuotaHandler(handler)` -Default: `allowMcpCall` +Registers the durable quota policy (opt-in — no handler registered means calls are allowed). The handler is called before each admitted `tools/call` with `{ identity, tool, user, profile, sessionId }` (`identity` may be `undefined` when no socket IP or header value is available). Return `true` (or any truthy non-object) 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`. Pass `undefined` to clear the handler. -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`. +Because the policy is a plain function and not a Resource, it exposes no `update_/delete_` MCP tools or REST/etc. surface — unlike an exported class, whose inherited CRUD would let a permitted client reset its own counter. Keep any table the handler uses for storage unexported (no `@export`), as above, so it stays off every transport. The latest registration wins, so a reloaded component replaces the previous handler; the single handler receives `profile`, so gate the operations and application profiles in code. 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. +- The handler runs **after** the in-memory buckets admit the call, so rate-limited clients cannot spam a table-backed handler. +- **Fail-closed**: a handler that throws **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 handler once per attempted tool call; counting strategy (increment on check vs on success) is the handler's business. +- **Race-safety is the handler's business too.** It 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.*`