Skip to content

Register the MCP durable quota policy as a function, not a config-referenced Resource (#1809)#1821

Merged
kylebernhardy merged 6 commits into
mainfrom
feat/mcp-quota-registration-1809
Jul 22, 2026
Merged

Register the MCP durable quota policy as a function, not a config-referenced Resource (#1809)#1821
kylebernhardy merged 6 commits into
mainfrom
feat/mcp-quota-registration-1809

Conversation

@kylebernhardy

@kylebernhardy kylebernhardy commented Jul 15, 2026

Copy link
Copy Markdown
Member

Closes #1809. Supersedes the interim docs fix in HarperFast/documentation#576.

Problem

The durable MCP quota hook (#1610) was configured by mcp.<profile>.quota.resource pointing at an exported Resource. Because Resources are public-by-default, that Resource's inherited CRUD surfaced on every transport (update_/delete_ MCP tools + REST/SSE/WS/GraphQL/MQTT), so a client subject to the quota could reset its own counter. The docs example papered over it by turning six exportTypes flags off — making the safe path the easy-to-forget one, for the one built-in whose entire purpose is server-side enforcement. Per @kriszyp's review on documentation#576, the fix belongs in the API, not the docs.

Change

Configure the quota policy with a registration function instead:

// in a component's resources.js, at module top level — no Resource, no config, no exposure
server.setMcpQuotaHandler(async ({ identity, tool, user, profile, sessionId }) => {
  if (profile !== 'application') return true;   // gate per-profile in code
  const used = await bumpCounter(identity);
  return used > DAILY_LIMIT ? { allowed: false, message: 'daily quota reached', retryAfterSeconds: 3600 } : true;
});
  • Enabled by registration, not config — register a handler → quota active; none → allowed (opt-in, unchanged). Throw → fail-closed deny (unchanged). Single global handler; latest registration wins (a reloaded component replaces it); profile is in the info so one handler covers both profiles.
  • Wired server.setMcpQuotaHandler next to server.registerOperation (serverUtilities.ts), declared on the Server interface. Worker-local like the tool dispatch that consults it, so no cross-thread announce (matches the old per-worker registry resolution).
  • Removed the mcp.<profile>.quota.resource/.method config params (only 5.2-alpha shipped them; no deprecated fallback).
  • Migrated the mcp-quota fixture off tables.QuotaCounter: an exported tool (Answerer) + an internal (non-@export) counter table + a registered handler — nothing exposes the counter. The integration test now asserts the counter is not REST-reachable.

Where to look

  • server.setMcpQuotaHandler wiring (serverUtilities.ts) — availability timing (set at server init, before components load) and the worker-local rationale.
  • The single-global-handler shape was chosen deliberately (Kyle's call): one handler branches on profile. Two components both registering means last-wins — acceptable for a single policy, worth a glance.
  • Integration test validated in CI — the mcp-quota end-to-end couldn't run locally (this machine lacks the 127.0.0.x loopback aliases the test framework needs); unit tests (quota.test.js) pass locally and the fixture mirrors the proven custom-resources extends Resource + mcpTools pattern.

Generated by an LLM (Claude Opus 4.8). Cross-model review (Codex + Gemini) in progress.


Docs: companion docs PR HarperFast/documentation#576 rewrites the reference/mcp/configuration.md durable-quota section to server.setMcpQuotaHandler (removes the mcp.<profile>.quota.* config); merges together.

…erenced Resource (#1809)

The quota hook was configured by `mcp.<profile>.quota.resource` pointing at an
exported Resource, whose inherited CRUD then surfaced on every transport
(update_/delete_ MCP tools + REST/SSE/WS/GraphQL/MQTT) — a permitted client
could reset its own counter. The docs example worked around it by turning six
exportTypes flags off, which made the safe path the easy-to-forget one.

Replace it with a registration function: `server.setMcpQuotaHandler(fn)`. The
policy is a plain function (never an exposed Resource), enabled by registering
it (no config). checkDurableQuota invokes the registered handler; no handler =>
allowed (opt-in), throw => fail-closed deny (unchanged). The handler receives
`profile` so one handler can gate operations vs application.

- Remove the `mcp.<profile>.quota.resource`/`.method` config params.
- Wire `server.setMcpQuotaHandler` next to `server.registerOperation`.
- Migrate the mcp-quota fixture off `tables.QuotaCounter`: an exported tool
  (Answerer) + an internal (non-@export) counter table + a registered handler,
  so nothing exposes the counter. Integration test asserts the counter is not
  REST-reachable.

Supersedes the docs#576 six-`false` example; docs follow-up separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
@kylebernhardy
kylebernhardy requested a review from kriszyp July 15, 2026 15:46

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the durable MCP quota policy mechanism by replacing the configuration-based resource lookup with a functional registration approach via server.setMcpQuotaHandler. This change simplifies the quota logic, removes obsolete configuration parameters, and ensures that the quota counter table remains internal and not exposed over REST. The review feedback correctly identifies that the setMcpQuotaHandler signatures in the Server interface and serverUtilities.ts should be updated to accept undefined to match the underlying implementation, allowing handlers to be cleared or unregistered.

Comment thread server/Server.ts Outdated
Comment thread server/serverHelpers/serverUtilities.ts Outdated
Comment thread server/Server.ts Outdated
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found. The previously-open finding (candidate quota handler live on the shared singleton during deploy-validation load) is resolved by commit 8da8768: registrations now no-op during validation instead of snapshot/restore, closing the race entirely and generalizing the same protection to server.registerOperation.

…earing (review)

Codex review:
- P1: the quota handler is a process-wide singleton, so a candidate component's
  top-level server.setMcpQuotaHandler(...) during deploy pre-flight validation
  would outlive the throwaway load and alter live enforcement on a failed deploy.
  Snapshot the handler before the validation load and restore it in the finally
  (added getMcpQuotaHandler()).
- P2: the Server interface rejected undefined though the setter supports clearing;
  widen the public type to McpQuotaHandler | undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread components/operations.js Outdated
…UD tools (#1809)

Codify the security property the redesign delivers (and that /verify checked by
hand): the counter table is internal, so GET /QuotaCounter 404s and tools/list
carries no QuotaCounter update_/delete_ tools a client could call to reset its
quota. Tightened the counter-not-exposed assertion from !=200 to ==404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread components/operations.js
Comment thread server/serverHelpers/serverUtilities.ts
…ion isolation (#1809)

The P1 snapshot/restore was inline in the deploy op and only its get/set primitive
was covered. Extract it into withMcpQuotaHandlerPreserved(fn) — operations.js wraps
the throwaway validation load in it — and unit-test the isolation directly: a
candidate that registers a different handler, one that clears it, and a load that
throws all leave the live worker's handler intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread components/operations.js Outdated
…view)

/code-review: assign server.setMcpQuotaHandler directly instead of a redundant
wrapper (also keeps the impl param type in sync with the Server interface's
McpQuotaHandler | undefined). Document that withMcpQuotaHandlerPreserved restores
unconditionally, so a legitimate interleaving registration would be reverted — a
narrow window, and the lesser evil vs leaking a candidate policy live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread components/operations.js Outdated
…egistrations (#1809)

/code-review #3: the deploy pre-flight snapshot/restore only isolated the quota
handler; a candidate's server.registerOperation during the throwaway validation
load still mutated the process-wide operation map AND announced to the main thread,
leaking onto the live worker on a failed deploy.

Replace the quota-specific withMcpQuotaHandlerPreserved with a general guard
(deployValidationState.ts): server.registerOperation and server.setMcpQuotaHandler
both no-op while a validation load is in flight (validation only needs to surface
load-time errors, not register anything). operations.js wraps the validation load
in runWithDeployValidationGuard. Skipping is cleaner than snapshot/restore here —
it also suppresses the cross-thread operation announce, which a local restore can't
undo. Depth-counted; the narrow interleaving caveat is documented.

Tests: registerOperation + setMcpQuotaHandler are skipped during validation and
resume after (incl. after a thrown load), in serverUtilities.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it!
As KrAIs said "Kyle didn't just build [the option we were discussing] — he improved on it"

@kylebernhardy
kylebernhardy merged commit 2738680 into main Jul 22, 2026
110 of 111 checks passed
@kylebernhardy
kylebernhardy deleted the feat/mcp-quota-registration-1809 branch July 22, 2026 04:07
dawsontoth added a commit that referenced this pull request Jul 22, 2026
Conflict in components/operations.js: main (#1809/#1821) wrapped the
throwaway deploy-validation load in runWithDeployValidationGuard so
process-wide server.* registrations (registerOperation, setMcpQuotaHandler)
are no-op'd during validation. This branch had already refactored that
inline load into the loadValidateComponent() helper. Resolved by keeping
the helper and porting main's guard into it — which also extends the guard
to the two-phase staging validation load (loadValidateComponent serves both
the one-shot and two-phase paths), not just the one-shot path main touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

QuotaCounter should default to unexported (internal-enforcement resources shouldn't be exposed on all transports by default)

3 participants