MCP Configuration
All MCP configuration lives under the top-level mcp: block in harperdb-config.yaml. Each profile (operations, application) is enabled by the presence of its sub-block — there is no separate enabled flag. A minimal "turn it on" config is therefore just:
mcp:
operations: {}
application: {}
That boots both profiles with default settings: the operations profile mounts at /mcp on the operations server, the application profile mounts at /mcp on the application HTTP server, and the default allow lists / rate limits apply.
mcp.operations.*
Configures the operations-profile MCP endpoint that wraps Harper's operation catalog.
mcp.operations.mountPath
Type: string
Default: /mcp
URL path the MCP endpoint mounts on. Change it if /mcp collides with another route in your application.
mcp.operations.allow
Type: array<string> (glob patterns or literal operation names)
Default: ['describe_*', 'list_*', 'search_*', 'get_job', 'get_status', 'get_analytics', 'get_metrics', 'system_information', 'read_log', 'read_audit_log']
Operations exposed as MCP tools. Glob * matches any sequence of characters; literal names match exactly. Setting allow replaces the default list; it does not merge. To add destructive or sensitive operations to the surface (e.g. set_configuration, drop_table), include them here explicitly.
The default list intentionally avoids get_* as a glob because that pulls in get_configuration (which can return TLS / S3 / authentication secrets), get_components / get_component_file / get_custom_function* (which return component source that can embed secrets), get_backup, and get_deployment*. These are all gated by verifyPerms at dispatch, but defaulting to "expose them to an LLM if a super_user calls them" is the wrong default — opt them in deliberately.
mcp.operations.deny
Type: array<string> (glob patterns or literal operation names)
Default: []
Operations to filter out after the allow list has been applied. Useful for taking back a single operation that a broad allow glob would otherwise expose.
mcp.operations.maxTools
Type: integer (minimum 1)
Default: 200
Maximum number of tools returned in a single tools/list response page. The MCP cursor is used to page through any overflow.
mcp.operations.rateLimit.*
See mcp.<profile>.rateLimit.* below — the schema is identical for both profiles.
Default per-profile values for operations: perToolPerSecond: 10, perToolBurst: 20, sessionConcurrency: 25, sessionPerSecond: 100.
mcp.application.*
Configures the application-profile MCP endpoint that walks your exported Resource classes. All mcp.operations.* keys above also apply here; the additional knob is:
mcp.application.searchMaxResults
Type: integer (minimum 1)
Default: 1000
Hard cap on the number of records a generated search_<resource> tool can return per call. Clients still pass limit, but the server clamps to this ceiling regardless of what the client requests — bounded to keep a runaway agent from exhausting memory.
Default per-profile rate-limit values for application: perToolPerSecond: 25, perToolBurst: 50, sessionConcurrency: 50, sessionPerSecond: 200.
mcp.<profile>.rateLimit.*
Per-session, per-tool token-bucket rate limits. A bucket exists per (session, tool) pair plus one per-session bucket across all tools. When either bucket is exhausted, tools/call returns result.isError = true with kind: "rate_limited" — not a JSON-RPC error — so the LLM can read the message and back off without the transport tearing down.
mcp.<profile>.rateLimit.perToolPerSecond
Type: number (minimum 0)
Default: 10 (operations) / 25 (application)
Sustained rate at which the per-tool token bucket refills. Set to 0 to disable per-tool throttling on this profile.
mcp.<profile>.rateLimit.perToolBurst
Type: number (minimum 0)
Default: 20 (operations) / 50 (application)
Burst capacity of the per-tool token bucket — how many back-to-back calls a single tool can absorb before sustained-rate refill kicks in.
mcp.<profile>.rateLimit.sessionConcurrency
Type: integer (minimum 0)
Default: 25 (operations) / 50 (application)
Maximum number of tools/call invocations a single session may have in flight at once. Subsequent attempts return kind: "rate_limited" with scope: "concurrency".
mcp.<profile>.rateLimit.sessionPerSecond
Type: number (minimum 0)
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.<profile>.rateLimit.perClientPerSecond
Type: number (minimum 0)
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. 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.<profile>.quota.*.
mcp.<profile>.rateLimit.perClientBurst
Type: number (minimum 0)
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 — 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.<profile>.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.<profile>.quota.*
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:
mcp:
application:
quota:
resource: McpQuota
// 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);
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.<profile>.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.<profile>.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/methodthat 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
awaitboundaries) 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.*
Settings that apply to MCP session lifecycle on both profiles.
mcp.session.idleTimeoutSeconds
Type: integer (minimum 1)
Default: 1800 (30 minutes)
Idle window after which a session record in system.mcp_session is TTL-evicted. The next request bearing the evicted session id receives HTTP 404 and the client is expected to re-initialize.
mcp.session.allowClientDelete
Type: boolean
Default: false
When true, Harper accepts client-issued DELETE /mcp requests that explicitly terminate a session. When false (the default), DELETE returns 405 with an Allow header — sessions only end via idle eviction or explicit server-side cleanup.
Security: Origin validation
The MCP endpoint validates the request Origin header to defend against DNS-rebinding attacks (a requirement of the MCP Streamable HTTP transport). Validation reuses each profile's existing CORS configuration rather than introducing a separate MCP setting:
- When CORS is disabled — or enabled with a
*(wildcard) allow-list — anyOriginis accepted. This is appropriate for localhost-only or non-browser clients, where no DNS-rebinding vector exists. - When CORS is enabled with an explicit allow-list, a request whose
Originis not in the list is rejected with403 Forbidden. - A request with no
Originheader at all (for examplecurlor server-to-server traffic) is always accepted — DNS rebinding only applies to browser-initiated requests.
Secure default: any deployment that exposes the MCP endpoint to browsers beyond loopback should enable CORS with an explicit (non-*) allow-list — that is what activates Origin-based DNS-rebinding protection.
The two profiles ship with different CORS defaults, but both accept any Origin out of the box:
- Application profile (HTTP port):
http.cors+http.corsAccessList. Default: CORS disabled, so anyOriginis accepted. - Operations profile (operations port):
operationsApi.network.cors+operationsApi.network.corsAccessList. Default: CORS enabled with a*allow-list, so anyOriginis still accepted until you replace*with explicit origins.
Example
A common deployment pattern that locks down the operations profile to a small explicit set, enables MCP DELETE for graceful client logout, and raises per-tool throughput for the application profile:
mcp:
operations:
allow:
- describe_all
- describe_database
- system_information
- get_job
rateLimit:
perToolPerSecond: 5
perToolBurst: 10
application:
searchMaxResults: 500
rateLimit:
perToolPerSecond: 50
perToolBurst: 100
session:
idleTimeoutSeconds: 3600
allowClientDelete: true