Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 44 additions & 83 deletions components/mcp/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,40 @@
*
* The in-memory buckets in `rateLimit.ts` bound instantaneous rates but are
* per-worker and reset on restart — insufficient as a COST control for a
* public unauthenticated tool (an LLM-backed `answer`, say). This hook lets
* the operator implement a durable policy (e.g. a persisted per-IP daily
* counter table) behind config:
* public unauthenticated tool (an LLM-backed `answer`, say). A component
* registers a durable policy (e.g. a persisted per-IP daily counter) as a
* function, so the policy is never itself an exposed Resource:
*
* mcp:
* application:
* quota:
* resource: McpQuota # exported Resource path
* method: allowMcpCall # static method on it (this is the default)
* // in a component's resources.js, at module top level
* 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' } : true;
* });
*
* Before each admitted tools/call, Harper calls
* `QuotaClass.allowMcpCall({ identity, tool, user, profile, sessionId })`.
* Return `true` (or any truthy non-object) to allow; return
* Before each admitted tools/call Harper calls the registered handler. Return
* `true` (or any truthy non-object) to allow; return
* `{ allowed: false, message?, retryAfterSeconds? }` to deny — the denial
* surfaces to the client as `isError` with `kind: 'quota_exceeded'`.
* Counting is the hook's business: increment on check, or on success via
* Counting is the handler's business: increment on check, or on success via
* your own bookkeeping — Harper calls once per attempted tool call.
*
* 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 (#1422 set this precedent
* for allow* hooks). The raw error goes to the server log only.
* FAIL-CLOSED: a handler that throws DENIES the call. Cost protection that
* silently disables itself on a bug is worse than a hard failure (#1422 set
* this precedent for allow* hooks). The raw error goes to the server log only.
*
* RACE-SAFETY: the hook can run concurrently for the SAME identity — within
* a worker (interleaving across the hook's own await boundaries) and across
* workers (separate processes sharing the database). A naive read-then-write
* counter (`get` → `put used+1`) can undercount under that concurrency and
* admit calls past the configured limit. Production implementations should
* make the read-modify-write atomic: run it in a transaction that serializes
* conflicting writers, use a compare-and-set retry loop, or maintain the
* counter in a store with native atomic increments.
* RACE-SAFETY: the handler can run concurrently for the SAME identity — within
* a worker (interleaving across its own await boundaries) and across workers
* (separate processes sharing the database). A naive read-then-write counter
* (`get` → `put used+1`) can undercount under that concurrency and admit calls
* past the limit. Production handlers should make the read-modify-write atomic:
* run it in a transaction that serializes conflicting writers, use a
* compare-and-set retry loop, or maintain the counter in a store with native
* atomic increments.
*
* Dispatch uses the LIVE registry class, same as custom tools — an exported
* subclass replacing the entry on reload wins.
* The latest registration wins, so a reloaded component replaces the previous
* handler.
*/
import * as env from '../../utility/environment/environmentManager.ts';
import { CONFIG_PARAMS } from '../../utility/hdbTerms.ts';
import harperLogger from '../../utility/logging/harper_logger.ts';
import type { McpProfile } from './transport.ts';
import type { AuthedUser } from './toolRegistry.ts';
Expand All @@ -62,67 +59,34 @@ export interface QuotaDenial {

export type QuotaDecision = { allowed: true } | QuotaDenial;

const CONFIG_KEYS: Record<McpProfile, { resource: string; method: string }> = {
operations: {
resource: CONFIG_PARAMS.MCP_OPERATIONS_QUOTA_RESOURCE,
method: CONFIG_PARAMS.MCP_OPERATIONS_QUOTA_METHOD,
},
application: {
resource: CONFIG_PARAMS.MCP_APPLICATION_QUOTA_RESOURCE,
method: CONFIG_PARAMS.MCP_APPLICATION_QUOTA_METHOD,
},
};

const DEFAULT_METHOD = 'allowMcpCall';

type ResourcesLike = Map<string, { Resource: unknown }> | undefined;

// Test seam — mirrors resources.ts: the real registry initializes the whole
// Harper graph at import, which unit tests can't do.
let _resourcesOverride: ResourcesLike;
export function _setQuotaResourcesForTest(r: ResourcesLike): void {
_resourcesOverride = r;
}
/**
* The durable quota policy: return `true` (or any truthy non-object) to allow,
* or `{ allowed: false, message?, retryAfterSeconds? }` to deny. Receives the
* per-call {@link QuotaCheckInfo}, including `profile`, so a single handler can
* gate the operations and application profiles differently.
*/
export type McpQuotaHandler = (info: QuotaCheckInfo) => QuotaDecision | boolean | Promise<QuotaDecision | boolean>;

function getResources(): ResourcesLike {
if (_resourcesOverride) return _resourcesOverride;
const { resources } = require('../../resources/Resources');
return resources as ResourcesLike;
}
// Single, process-wide handler. Registered by a component via
// `server.setMcpQuotaHandler(fn)`; the latest registration wins (so a reloaded
// component replaces the previous one), and `undefined` clears it.
let quotaHandler: McpQuotaHandler | undefined;

/** Warn-once-per-profile state for a misconfigured hook (missing resource/method). */
const warnedMisconfigured = new Set<McpProfile>();
export function _resetQuotaWarningsForTest(): void {
warnedMisconfigured.clear();
export function setMcpQuotaHandler(handler: McpQuotaHandler | undefined): void {
quotaHandler = handler;
}

/**
* Run the configured durable quota hook, if any. Returns `{allowed: true}`
* when no hook is configured (the feature is opt-in). Misconfiguration and
* hook errors DENY (fail-closed) with a sanitized message.
* Run the registered durable quota handler, if any. Returns `{allowed: true}`
* when no handler is registered (the feature is opt-in). A handler that throws
* DENIES (fail-closed) with a sanitized message.
*/
export async function checkDurableQuota(info: QuotaCheckInfo): Promise<QuotaDecision> {
const keys = CONFIG_KEYS[info.profile];
const resourcePath = env.get(keys.resource);
if (typeof resourcePath !== 'string' || !resourcePath) {
if (!quotaHandler) {
return { allowed: true };
}
const methodName =
typeof env.get(keys.method) === 'string' && env.get(keys.method) ? env.get(keys.method) : DEFAULT_METHOD;
const entry = getResources()?.get(resourcePath);
const QuotaClass = entry?.Resource as Record<string, unknown> | undefined;
const method = QuotaClass?.[methodName as string];
if (typeof method !== 'function') {
if (!warnedMisconfigured.has(info.profile)) {
warnedMisconfigured.add(info.profile);
harperLogger.warn(
`MCP ${info.profile} quota hook misconfigured: no exported resource '${resourcePath}' with static method '${methodName}'; DENYING tool calls (fail-closed)`
);
}
return { allowed: false, message: 'quota policy unavailable' };
}
try {
const result = await (method as (i: QuotaCheckInfo) => unknown).call(QuotaClass, info);
const result = await quotaHandler(info);
if (result && typeof result === 'object') {
const decision = result as { allowed?: unknown; message?: unknown; retryAfterSeconds?: unknown };
if (decision.allowed === false) {
Expand All @@ -136,10 +100,7 @@ export async function checkDurableQuota(info: QuotaCheckInfo): Promise<QuotaDeci
}
return result ? { allowed: true } : { allowed: false };
} catch (error) {
harperLogger.error(
`MCP ${info.profile} quota hook '${resourcePath}.${methodName}' threw; denying (fail-closed)`,
error
);
harperLogger.error(`MCP ${info.profile} quota handler threw; denying (fail-closed)`, error);
return { allowed: false, message: 'quota check failed' };
}
}
9 changes: 7 additions & 2 deletions components/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,12 @@ async function deployComponent(req) {
// deploy-lifecycle listeners on this worker, eventually tripping MaxListenersExceededWarning
// (#1462).
const validationScopes = new Set();
const validation = (async () => {
// Process-wide `server.*` registrations (registerOperation, setMcpQuotaHandler) are not owned by
// a Scope, so a candidate's top-level registration during this throwaway load would otherwise
// outlive it and pollute the live worker on a failed/rolled-back deploy. The guard makes those
// registration methods no-op for the duration of the load.
const { runWithDeployValidationGuard } = require('../server/serverHelpers/deployValidationState.ts');
const validation = runWithDeployValidationGuard(async () => {
try {
await componentLoader.loadComponent(application.dirPath, pseudoResources, undefined, {
collectScopes: validationScopes,
Expand All @@ -541,7 +546,7 @@ async function deployComponent(req) {
if (result.status === 'rejected') log.warn('Failed to close a deploy-validation Scope', result.reason);
}
}
})();
});
// Track the load+close so a concurrent worker shutdown waits for these scopes to finish
// disposing — a plugin may start a native runtime in handleApplication — before realExit.
trackScopeClose(validation);
Expand Down
5 changes: 3 additions & 2 deletions integrationTests/fixtures/mcp-quota/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Fixture for MCP per-client-identity rate limiting + the durable quota hook
# (#1610). The McpQuota class carries a cost-bearing custom tool and the
# config-named quota policy backed by the QuotaCounter table.
# (#1610/#1809). Answerer exposes a cost-bearing custom tool; the quota policy is
# registered as a function via server.setMcpQuotaHandler and backed by the
# internal (non-exported) QuotaCounter table.
graphqlSchema:
files: '*.graphql'
jsResource:
Expand Down
37 changes: 20 additions & 17 deletions integrationTests/fixtures/mcp-quota/resources.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
// #1610 fixture: a cost-bearing custom tool plus the config-named durable
// quota policy. `allowMcpCall` increments a persisted per-identity counter and
// denies past DAILY_LIMIT — the reporter's public-docs `answer` tool shape.
// #1610 / #1809 fixture: a cost-bearing custom MCP tool plus a durable quota
// policy registered as a FUNCTION (server.setMcpQuotaHandler). The policy is
// never an exposed Resource, and the counter it uses is an internal (non-@export)
// table, so no client can read or reset its own counter over any transport.
//
// NOTE: the get-then-put below is NOT race-safe — concurrent calls for one
// identity can interleave between the read and the write and undercount. Fine
// for this single-threaded test instance; production quota implementations
// must make the read-modify-write atomic (see the RACE-SAFETY note in
// components/mcp/quota.ts).
// for this single-threaded test instance; production handlers must make the
// read-modify-write atomic (see the RACE-SAFETY note in components/mcp/quota.ts).

const DAILY_LIMIT = 3;

export class McpQuota extends tables.QuotaCounter {
// The cost-bearing tool clients call (exported — this is the public surface).
export class Answerer extends Resource {
static mcpTools = [
{
name: 'answer',
Expand All @@ -23,15 +24,17 @@ export class McpQuota extends tables.QuotaCounter {
async doAnswer(args) {
return { answered: args?.q ?? '' };
}
}

static async allowMcpCall({ identity }) {
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;
// Durable quota policy, registered as a function and backed by the internal
// QuotaCounter table — no config points at a Resource, and nothing is exposed.
server.setMcpQuotaHandler(async ({ identity }) => {
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;
});
6 changes: 4 additions & 2 deletions integrationTests/fixtures/mcp-quota/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Persisted per-identity counter behind the durable quota hook (#1610).
type QuotaCounter @table @export {
# Internal per-identity counter behind the durable quota handler (#1610/#1809).
# NOT @export — the handler reaches it via tables.QuotaCounter, and no client can
# read or reset it over any transport.
type QuotaCounter @table {
id: ID @primaryKey
used: Int
}
50 changes: 41 additions & 9 deletions integrationTests/mcp/quota.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* #1610 — per-client-identity rate limiting + the durable quota hook.
* #1610/#1809 — per-client-identity rate limiting + the durable quota handler.
*
* The instance configures identityHeader: x-test-client (so the test controls
* identity per call), a per-client bucket of burst 6 with negligible refill,
* and quota.resource: McpQuota (persisted per-identity counter, limit 3).
* identity per call) and a per-client bucket of burst 6 with negligible refill.
* The fixture registers the durable quota policy via server.setMcpQuotaHandler
* (limit 3), backed by an internal per-identity counter table.
* Every tools/call opens a FRESH session — the session-cycling abuse loop the
* issue describes — so anything that throttles here is client-scoped, not
* session-scoped. Expected ladder for one identity:
Expand Down Expand Up @@ -90,7 +91,6 @@ suite('MCP per-client rate limit + durable quota (#1610)', (ctx: ContextWithHarp
perClientPerSecond: 0.001,
perClientBurst: 6,
},
quota: { resource: 'McpQuota' },
},
},
},
Expand Down Expand Up @@ -133,15 +133,47 @@ suite('MCP per-client rate limit + durable quota (#1610)', (ctx: ContextWithHarp
strictEqual(payload.retryAfterSeconds, 3600);
});

test('a different client identity is unaffected and the counter persists per identity', async () => {
test('a different client identity is unaffected; the internal counter is not client-reachable', async () => {
// A fresh identity starts with a clean quota (per-identity isolation; persistence
// per identity is covered by the ok,ok,ok→quota_exceeded sequence above).
const body = await callAnswerFreshSession('client-c');
strictEqual(body.result?.isError ?? false, false, `fresh identity admitted: ${JSON.stringify(body)}`);
// The durable counter is a real table row, visible over REST.
// The counter table is internal (not @export), so — unlike the old exported-Resource
// approach — no client can read or reset it over REST. The route should not resolve.
const res = await fetch(new URL('/QuotaCounter/client-c', ctx.harper.httpURL), {
headers: { authorization: auth },
});
strictEqual(res.status, 200);
const record = await res.json();
strictEqual(record.used, 1);
strictEqual(res.status, 404, `internal counter must not be REST-exposed (got ${res.status})`);
});

test('the internal counter exposes no MCP CRUD tools (no way to reset a quota)', async () => {
const baseHeaders = {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'authorization': auth,
};
const initRes = await fetch(new URL('/mcp', ctx.harper.httpURL), {
method: 'POST',
headers: baseHeaders,
body: JSON.stringify({
jsonrpc: '2.0',
id: ++rpcId,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'quota-it', version: '0' } },
}),
});
const sessionId = initRes.headers.get('mcp-session-id');
const listRes = await fetch(new URL('/mcp', ctx.harper.httpURL), {
method: 'POST',
headers: { ...baseHeaders, 'mcp-session-id': sessionId as string, 'mcp-protocol-version': '2025-06-18' },
body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/list', params: {} }),
});
const body = JSON.parse(await listRes.text());
const names: string[] = (body.result?.tools ?? []).map((t: { name: string }) => t.name);
ok(names.includes('answer'), `the cost-bearing tool is exposed: ${names.join(', ')}`);
// Under the old exported-Resource design the counter surfaced update_/delete_ tools a client
// could call to reset its quota; the internal table must expose none.
const counterCrud = names.filter((n) => /quotacounter/i.test(n));
strictEqual(counterCrud.length, 0, `no QuotaCounter CRUD tools should exist, found: ${counterCrud.join(', ')}`);
});
});
3 changes: 3 additions & 0 deletions server/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Socket } from 'net';
import { _assignPackageExport } from '../globals.js';
import type { Value } from '../resources/analytics/write.ts';
import type { Resources } from '../resources/Resources.ts';
import type { McpQuotaHandler } from '../components/mcp/quota.ts';
import { OperationDefinition } from './serverHelpers/serverUtilities.ts';
import { Duplex } from 'stream';
import { Request } from './serverHelpers/Request.ts';
Expand Down Expand Up @@ -32,6 +33,8 @@ export interface Server {
authenticateUser(username: string, password: string, request: Request): any;
operation(operation: any, context: any, authorize?: boolean): Promise<any>;
registerOperation(operationDefinition: OperationDefinition): void;
/** Register the durable MCP quota policy (opt-in). The latest registration wins; pass `undefined` to clear. */
setMcpQuotaHandler(handler: McpQuotaHandler | undefined): void;
recordAnalytics(value: Value, metric: string, path?: string, method?: string, type?: string): void;
nodes: Node[];
shards: Map<number, string[]>;
Expand Down
25 changes: 25 additions & 0 deletions server/serverHelpers/deployValidationState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Deploy pre-flight validation loads throwaway candidate component code purely to surface load-time
// errors — its top-level `server.registerOperation` / `server.setMcpQuotaHandler` calls mutate
// process-wide state (the operation map, its cross-thread announcement, the quota handler) that is
// NOT owned by a Scope, so a failed/rolled-back deploy would otherwise leak the candidate's
// registrations onto the live worker. Those registration methods no-op while this guard is active.
//
// Depth-counted so overlapping validations both keep the guard raised until the last one finishes.
// Caveat: while a validation is in flight, a legitimate registration from an interleaving real load
// is also skipped (it re-registers on its next load); that window is narrow, and the alternative —
// leaking a candidate's registration live — is worse.
let validationDepth = 0;

export function isDeployValidating(): boolean {
return validationDepth > 0;
}

/** Run `fn` (the throwaway validation load) with the guard raised, lowering it even if `fn` throws. */
export async function runWithDeployValidationGuard<T>(fn: () => Promise<T>): Promise<T> {
validationDepth++;
try {
return await fn();
} finally {
validationDepth--;
}
}
Loading