Skip to main content
Version: v5

MCP Configuration

Added in: v5.1.0

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/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.*

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 — any Origin is 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 Origin is not in the list is rejected with 403 Forbidden.
  • A request with no Origin header at all (for example curl or 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 any Origin is accepted.
  • Operations profile (operations port): operationsApi.network.cors + operationsApi.network.corsAccessList. Default: CORS enabled with a * allow-list, so any Origin is 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