@harperfast/template-react-ts-studio 1.12.7 → 1.12.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.agents/skills/harper-best-practices/AGENTS.md +514 -445
  2. package/.agents/skills/harper-best-practices/rules/automatic-apis.md +96 -93
  3. package/.agents/skills/harper-best-practices/rules/deploying-to-harper-fabric.md +84 -58
  4. package/.agents/skills/harper-best-practices/rules/extending-tables.md +27 -35
  5. package/.agents/skills/harper-best-practices/rules/handling-binary-data.md +40 -39
  6. package/.agents/skills/harper-best-practices/rules/querying-rest-apis.md +93 -37
  7. package/.agents/skills/harper-best-practices/rules/schema-design-tooling.md +68 -66
  8. package/.agents/skills/harper-best-practices/rules/using-blob-datatype.md +24 -28
  9. package/.agents/skills/harper-best-practices/rules/v5-upgrade.md +98 -105
  10. package/.agents/skills/harper-best-practices/rules.manifest.yaml +28 -3
  11. package/.agents/skills/harper-local-dev/AGENTS.md +197 -0
  12. package/.agents/skills/harper-local-dev/SKILL.md +65 -0
  13. package/.agents/skills/harper-local-dev/rules/running-dev-instances-in-worktrees.md +198 -0
  14. package/.agents/skills/harper-local-dev/rules.manifest.yaml +20 -0
  15. package/.agents/skills/harper-local-dev/scripts/harper-dev.mjs +345 -0
  16. package/.agents/skills/harper-mcp/AGENTS.md +138 -26
  17. package/.agents/skills/harper-mcp/rules/enabling-mcp.md +140 -28
  18. package/agent/skills/harper-best-practices/AGENTS.md +514 -445
  19. package/agent/skills/harper-best-practices/rules/automatic-apis.md +96 -93
  20. package/agent/skills/harper-best-practices/rules/deploying-to-harper-fabric.md +84 -58
  21. package/agent/skills/harper-best-practices/rules/extending-tables.md +27 -35
  22. package/agent/skills/harper-best-practices/rules/handling-binary-data.md +40 -39
  23. package/agent/skills/harper-best-practices/rules/querying-rest-apis.md +93 -37
  24. package/agent/skills/harper-best-practices/rules/schema-design-tooling.md +68 -66
  25. package/agent/skills/harper-best-practices/rules/using-blob-datatype.md +24 -28
  26. package/agent/skills/harper-best-practices/rules/v5-upgrade.md +98 -105
  27. package/agent/skills/harper-best-practices/rules.manifest.yaml +28 -3
  28. package/agent/skills/harper-local-dev/AGENTS.md +197 -0
  29. package/agent/skills/harper-local-dev/SKILL.md +64 -0
  30. package/agent/skills/harper-local-dev/rules/running-dev-instances-in-worktrees.md +198 -0
  31. package/agent/skills/harper-local-dev/rules.manifest.yaml +20 -0
  32. package/agent/skills/harper-local-dev/scripts/harper-dev.mjs +345 -0
  33. package/agent/skills/harper-mcp/AGENTS.md +138 -26
  34. package/agent/skills/harper-mcp/rules/enabling-mcp.md +140 -28
  35. package/package.json +1 -1
  36. package/skills-lock.json +8 -2
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ // Start `harper dev .` for this checkout on its own loopback address with its own data root, so
3
+ // several git worktrees (or agent sessions) can each run a Harper dev instance at the same time.
4
+ //
5
+ // Copy into a Harper app (e.g. scripts/harper-dev.mjs) and point the dev script at it:
6
+ // "dev": "node scripts/harper-dev.mjs"
7
+ // Extra arguments are passed through to Harper: npm run dev -- --LOGGING_LEVEL=debug
8
+ // Print this checkout's running instance's URL, or fail if there is none: node scripts/harper-dev.mjs url
9
+ //
10
+ // Environment:
11
+ // HARPER_BIN Harper executable (default: `harper` on PATH)
12
+ // HARPER_DEV_HOME where data roots and address claims live (default: ~/.harper-dev)
13
+ // HARPER_DEV_LOOPBACK_START/COUNT loopback addresses to use (default: 127.0.0.2 through 127.0.0.33)
14
+ // HDB_ADMIN_USERNAME/PASSWORD admin user created with a new data root (default: admin / random)
15
+ //
16
+ // Background: https://github.com/HarperFast/skills/blob/main/harper-local-dev/rules/running-dev-instances-in-worktrees.md
17
+
18
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
19
+ import { createHash, randomBytes } from 'node:crypto';
20
+ import fs from 'node:fs';
21
+ import net from 'node:net';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+
25
+ // Every listener gets the same default port on this instance's own address.
26
+ const PORTS = {
27
+ HTTP_PORT: 9926,
28
+ OPERATIONSAPI_NETWORK_PORT: 9925,
29
+ MQTT_NETWORK_PORT: 1883,
30
+ MQTT_NETWORK_SECUREPORT: 8883,
31
+ REPLICATION_SECUREPORT: 9933, // Harper Pro only; core ignores it
32
+ };
33
+ const DEBUGGER_PORT = 9229;
34
+ // Env vars set to this instance's URL. Add your app's own base-URL variables (e.g. 'APP_BASE_URL'): values set
35
+ // here win over .env, because Harper's loadEnv never overrides a variable that is already set.
36
+ const URL_ENV_VARS = ['HARPER_DEV_URL'];
37
+ const LOOPBACK_START = Number(process.env.HARPER_DEV_LOOPBACK_START ?? 2);
38
+ const LOOPBACK_COUNT = Number(process.env.HARPER_DEV_LOOPBACK_COUNT ?? 32);
39
+ const INSTANCE_FILE = '.harper-instance';
40
+ const harperBin = process.env.HARPER_BIN ?? 'harper';
41
+
42
+ const appDir = fs.realpathSync(process.cwd());
43
+ const id = createHash('sha256').update(appDir).digest('hex').slice(0, 12);
44
+ const devHome = path.resolve(process.env.HARPER_DEV_HOME ?? path.join(os.homedir(), '.harper-dev'));
45
+ const { project, checkout } = checkoutNames();
46
+ // Data roots are grouped by project so an agent sandbox can allow writes to one project's directory only.
47
+ const projectDir = path.join(devHome, project);
48
+ const rootPath = path.join(projectDir, `${checkout}-${id}`);
49
+ // Held while the wrapper runs, so two starts from one checkout can't race each other through install or startup.
50
+ const lockFile = `${rootPath}.lock`;
51
+ // Address claims are machine-wide and must not depend on $TMPDIR, which agent sandboxes redirect.
52
+ const claimDir = path.join(devHome, '.loopback');
53
+ const instanceFile = path.join(appDir, INSTANCE_FILE);
54
+
55
+ function fail(message) {
56
+ console.error(`harper-dev: ${message}`);
57
+ process.exit(1);
58
+ }
59
+
60
+ // The project is the repository the checkout belongs to, shared by all of its worktrees; the checkout is the
61
+ // worktree's own directory. Outside git, both are the app directory's name. Names are kept short because the
62
+ // operations API's Unix socket lives in the data root, and macOS limits socket paths to 103 bytes.
63
+ function checkoutNames() {
64
+ let top = appDir;
65
+ let common = appDir;
66
+ try {
67
+ const args = ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'];
68
+ const output = execFileSync('git', args, { cwd: appDir, encoding: 'utf8', stdio: 'pipe' });
69
+ [top, common] = output.trim().split('\n');
70
+ // <repo>/.git, or <repo>/.bare or <repo>.git for a bare repository with worktrees
71
+ if (path.basename(common).startsWith('.')) common = path.dirname(common);
72
+ } catch {}
73
+ const clean = (name, length) =>
74
+ name
75
+ .replace(/\.git$/, '')
76
+ .replace(/[^\w.-]+/g, '-')
77
+ .slice(0, length) || 'app';
78
+ return { project: clean(path.basename(common), 20), checkout: clean(path.basename(top), 16) };
79
+ }
80
+
81
+ function isAlive(pid) {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ } catch (error) {
86
+ return error.code === 'EPERM';
87
+ }
88
+ }
89
+
90
+ function readPid(file) {
91
+ try {
92
+ return Number(fs.readFileSync(file, 'utf8').trim()) || 0;
93
+ } catch {
94
+ return 0;
95
+ }
96
+ }
97
+
98
+ // Atomically create a lock file holding this process's PID. A lock left by a process that has died is taken over.
99
+ function tryLock(file) {
100
+ for (let attempt = 0; attempt < 2; attempt++) {
101
+ try {
102
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
103
+ return true;
104
+ } catch (error) {
105
+ if (error.code !== 'EEXIST') throw error;
106
+ const owner = readPid(file);
107
+ // An empty file is a lock being written right now.
108
+ if (!owner || isAlive(owner)) return false;
109
+ fs.rmSync(file, { force: true });
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // The instance recorded in .harper-instance, if it is still running. The file outlives a wrapper that crashed, and
116
+ // its address may since have gone to another checkout, so the recorded wrapper must be alive and still hold both
117
+ // this checkout's lock and the address claim.
118
+ function liveInstance() {
119
+ try {
120
+ const instance = JSON.parse(fs.readFileSync(instanceFile, 'utf8'));
121
+ const holds = (file) => readPid(file) === instance.pid;
122
+ if (isAlive(instance.pid) && holds(lockFile) && holds(path.join(claimDir, instance.host))) {
123
+ return instance;
124
+ }
125
+ } catch {}
126
+ }
127
+
128
+ // Resolves to 'free' or the error code: EADDRINUSE (something already listens there), EADDRNOTAVAIL (address not
129
+ // configured), or EPERM/EACCES (binding is blocked, typically by an agent sandbox).
130
+ function probe(host, port) {
131
+ return new Promise((resolve) => {
132
+ const server = net.createServer();
133
+ server.once('error', (error) => resolve(error.code));
134
+ server.listen({ host, port }, () => server.close(() => resolve('free')));
135
+ });
136
+ }
137
+
138
+ async function allocateAddress() {
139
+ // Start from a slot derived from the checkout path, so a worktree usually gets the same address every time.
140
+ const preferred = parseInt(id.slice(0, 8), 16) % LOOPBACK_COUNT;
141
+ let unavailable = 0;
142
+ for (let i = 0; i < LOOPBACK_COUNT; i++) {
143
+ const host = `127.0.0.${LOOPBACK_START + ((preferred + i) % LOOPBACK_COUNT)}`;
144
+ const claimFile = path.join(claimDir, host);
145
+ if (!tryLock(claimFile)) continue;
146
+ const results = await Promise.all(
147
+ [...Object.values(PORTS), DEBUGGER_PORT].map((port) => probe(host, port)),
148
+ );
149
+ if (results.every((result) => result === 'free')) return { host, claimFile };
150
+ fs.rmSync(claimFile, { force: true });
151
+ if (results.some((result) => result === 'EPERM' || result === 'EACCES')) {
152
+ fail(
153
+ `not permitted to listen on ${host}. In an agent sandbox, allow binding local ports ` +
154
+ '(Claude Code on macOS: sandbox.network.allowLocalBinding).',
155
+ );
156
+ }
157
+ if (results.includes('EADDRNOTAVAIL')) unavailable++;
158
+ }
159
+ const last = LOOPBACK_START + LOOPBACK_COUNT - 1;
160
+ if (unavailable === LOOPBACK_COUNT) {
161
+ fail(
162
+ `loopback addresses 127.0.0.${LOOPBACK_START}-127.0.0.${last} are not configured. ` +
163
+ 'On macOS, alias them once per boot:\n' +
164
+ ` for i in $(seq ${LOOPBACK_START} ${last}); do sudo ifconfig lo0 alias 127.0.0.$i up; done`,
165
+ );
166
+ }
167
+ fail(
168
+ `no free loopback address in 127.0.0.${LOOPBACK_START}-127.0.0.${last}. Stop some Harper instances, ` +
169
+ 'or check for one bound to all interfaces (a plain `harper dev .`).',
170
+ );
171
+ }
172
+
173
+ // Harper's loadEnv never overrides a variable that is already set, even to an empty string, so an empty
174
+ // value inherited from the shell would silently mask the value in .env. Drop those before starting Harper.
175
+ function unmaskDotEnv() {
176
+ let text;
177
+ try {
178
+ text = fs.readFileSync(path.join(appDir, '.env'), 'utf8');
179
+ } catch {
180
+ return;
181
+ }
182
+ for (const [, key] of text.matchAll(/^\s*(?:export\s+)?([\w.-]+)\s*=/gm)) {
183
+ if (process.env[key] === '') delete process.env[key];
184
+ }
185
+ }
186
+
187
+ // Installs a new data root with `harper install` under a throwaway HOME. When ~/.harperdb has no boot file, an
188
+ // install writes one pointing at its data root; that would repoint the machine's bare `harper` commands at this
189
+ // instance, and agent sandboxes usually forbid the write.
190
+ function install() {
191
+ const installHome = path.join(projectDir, `.install-${id}`);
192
+ fs.mkdirSync(installHome, { recursive: true });
193
+ const result = spawnSync(harperBin, ['install'], {
194
+ stdio: 'inherit',
195
+ env: {
196
+ ...process.env,
197
+ HOME: installHome,
198
+ ROOTPATH: rootPath,
199
+ DEFAULTS_MODE: process.env.DEFAULTS_MODE ?? 'dev',
200
+ HDB_ADMIN_USERNAME: adminUsername,
201
+ HDB_ADMIN_PASSWORD: adminPassword,
202
+ },
203
+ });
204
+ fs.rmSync(installHome, { recursive: true, force: true });
205
+ if (result.status !== 0 || !fs.existsSync(path.join(rootPath, 'harper-config.yaml'))) {
206
+ fs.rmSync(rootPath, { recursive: true, force: true });
207
+ fail(
208
+ result.error?.code === 'ENOENT'
209
+ ? 'could not find the `harper` executable; install Harper (npm install -g harper) or set HARPER_BIN.'
210
+ : 'installing a new data root failed; see the output above.',
211
+ );
212
+ }
213
+ }
214
+
215
+ // Harper's operations API listens on a Unix socket in the data root and fails to start if it may not bind one, which
216
+ // agent sandboxes usually forbid. Probe for that, and turn the socket off when it would fail.
217
+ function canBindUnixSocket() {
218
+ const socketPath = path.join(projectDir, `.socket-check-${process.pid}`);
219
+ return new Promise((resolve) => {
220
+ const server = net.createServer();
221
+ server.once('error', (error) => resolve(error.code !== 'EPERM' && error.code !== 'EACCES'));
222
+ server.listen(socketPath, () => server.close(() => resolve(true)));
223
+ }).finally(() => fs.rmSync(socketPath, { force: true }));
224
+ }
225
+
226
+ if (process.argv[2] === 'url') {
227
+ const instance = liveInstance();
228
+ if (!instance)
229
+ fail('no Harper dev instance is running for this checkout; start one with `npm run dev`.');
230
+ console.log(instance.url);
231
+ process.exit(0);
232
+ }
233
+
234
+ const relative = path.relative(appDir, rootPath);
235
+ if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
236
+ fail(
237
+ `data root ${rootPath} is inside the app directory; set HARPER_DEV_HOME to a directory outside it.`,
238
+ );
239
+ }
240
+
241
+ for (const dir of [projectDir, claimDir]) {
242
+ try {
243
+ fs.mkdirSync(dir, { recursive: true });
244
+ // Write a file rather than trusting access(): sandboxes deny writes that permission bits allow.
245
+ const probeFile = path.join(dir, `.write-check-${process.pid}`);
246
+ fs.writeFileSync(probeFile, '');
247
+ fs.rmSync(probeFile);
248
+ } catch {
249
+ fail(
250
+ `cannot write to ${dir}. In an agent sandbox, allow writes to ${devHome} ` +
251
+ `(or to just ${projectDir} and ${claimDir}).`,
252
+ );
253
+ }
254
+ }
255
+
256
+ let cleanedUp = false;
257
+ let claimFile;
258
+ function cleanUp() {
259
+ if (cleanedUp) return;
260
+ cleanedUp = true;
261
+ try {
262
+ if (JSON.parse(fs.readFileSync(instanceFile, 'utf8')).pid === process.pid)
263
+ fs.rmSync(instanceFile);
264
+ } catch {}
265
+ for (const file of [claimFile, lockFile]) {
266
+ if (file && readPid(file) === process.pid) fs.rmSync(file, { force: true });
267
+ }
268
+ }
269
+
270
+ if (!tryLock(lockFile)) {
271
+ const running = liveInstance();
272
+ const owner = readPid(lockFile);
273
+ fail(
274
+ `Harper is already running for this checkout${running ? ` at ${running.url}` : ''}` +
275
+ `${owner ? ` (pid ${owner})` : ''}.`,
276
+ );
277
+ }
278
+ process.on('exit', cleanUp);
279
+
280
+ const harperPid = readPid(path.join(rootPath, 'hdb.pid'));
281
+ if (harperPid && isAlive(harperPid)) {
282
+ fail(
283
+ `a Harper process from an earlier run is still using ${rootPath}; stop it with \`kill ${harperPid}\`.`,
284
+ );
285
+ }
286
+
287
+ unmaskDotEnv();
288
+ let host;
289
+ ({ host, claimFile } = await allocateAddress());
290
+
291
+ const firstRun = !fs.existsSync(path.join(rootPath, 'harper-config.yaml'));
292
+ const adminUsername = process.env.HDB_ADMIN_USERNAME || 'admin';
293
+ const adminPassword = process.env.HDB_ADMIN_PASSWORD || randomBytes(12).toString('base64url');
294
+ if (firstRun) {
295
+ console.log(`Installing a new Harper data root for ${appDir}`);
296
+ install();
297
+ }
298
+
299
+ const url = `http://${host}:${PORTS.HTTP_PORT}`;
300
+ const operationsUrl = `http://${host}:${PORTS.OPERATIONSAPI_NETWORK_PORT}`;
301
+ const unixSocket = (await canBindUnixSocket()) && path.join(rootPath, 'operations-server');
302
+ const env = { ...process.env };
303
+ for (const name of URL_ENV_VARS) env[name] = url;
304
+
305
+ const args = [
306
+ 'dev',
307
+ '.',
308
+ `--ROOTPATH=${rootPath}`,
309
+ ...Object.entries(PORTS).map(([key, port]) => `--${key}=${host}:${port}`),
310
+ `--OPERATIONSAPI_NETWORK_DOMAINSOCKET=${unixSocket}`,
311
+ `--THREADS_DEBUG_HOST=${host}`,
312
+ ...process.argv.slice(2),
313
+ ];
314
+
315
+ fs.writeFileSync(
316
+ instanceFile,
317
+ JSON.stringify({ url, operationsUrl, host, rootPath, pid: process.pid }, null, '\t') + '\n',
318
+ );
319
+
320
+ console.log(`Harper dev instance for ${appDir}`);
321
+ console.log(` URL: ${url}`);
322
+ console.log(
323
+ ` Operations: ${operationsUrl}${unixSocket ? '' : ' (Unix socket off: not permitted here)'}`,
324
+ );
325
+ console.log(` Data root: ${rootPath}`);
326
+ if (firstRun && !process.env.HDB_ADMIN_PASSWORD) {
327
+ console.log(` Admin: ${adminUsername} / ${adminPassword}`);
328
+ }
329
+
330
+ const child = spawn(harperBin, args, { stdio: 'inherit', env });
331
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
332
+ process.on(signal, () => child.kill(signal));
333
+ }
334
+ child.on('error', (error) => {
335
+ cleanUp();
336
+ fail(
337
+ error.code === 'ENOENT'
338
+ ? 'could not find the `harper` executable; install Harper (npm install -g harper) or set HARPER_BIN.'
339
+ : error.message,
340
+ );
341
+ });
342
+ child.on('exit', (code, signal) => {
343
+ cleanUp();
344
+ process.exit(code ?? (signal ? 1 : 0));
345
+ });
@@ -4,55 +4,167 @@ Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) serv
4
4
 
5
5
  ## 1. Setup & Connection
6
6
 
7
- ### 1.1 Enabling MCP
7
+ ### 1.1 Enabling and Configuring Harper MCP Server Profiles
8
8
 
9
- Instructions for exposing a Harper instance as a Model Context Protocol (MCP) server.
9
+ Instructions for the agent to enable and configure Harper's MCP server profiles (`operations` and `application`) using the `mcp:` block in `harper-config.yaml`.
10
10
 
11
11
  #### When to Use
12
12
 
13
- Use this skill when an AI client (Claude, an agent framework, or any MCP-capable tool) should talk to Harper directly — calling tools backed by your tables and Resources, reading data as MCP resources, or driving Harper operations.
13
+ Apply this rule when adding MCP support to a Harper deployment so that LLM hosts (Claude Desktop, Cursor, Zed, custom agents) can discover and invoke Harper operations and resources. Use it when configuring rate limits, session lifecycle, allow/deny lists, or durable quota policies for either MCP profile. See [connecting-clients.md](connecting-clients.md) for wiring an MCP client to a running Harper instance.
14
14
 
15
15
  #### How It Works
16
16
 
17
- Harper ships two independent MCP **profiles**, each a Streamable HTTP endpoint (MCP spec rev 2025-06-18):
17
+ 1. **Enable profiles via presence of their sub-block.** A profile is enabled by the **presence** of its sub-block under `mcp:` — there is no separate `enabled` flag. Add an empty map to enable a profile with all defaults:
18
18
 
19
- 1. **`application` profile** — the profile most apps want. Exposes your application's surface: auto-generated CRUD verb tools for `@export`ed tables, plus anything components opt in via `static mcpTools` / `static mcpPrompts` / `static mcpResources`. Mounts on the application HTTP port (default `9926`).
20
- 2. **`operations` profile** — exposes Harper's operations API as tools (user-filtered by operation permissions). Mounts on the operations port (default `9925`). Intended for administrative agents, not application clients.
19
+ ```yaml
20
+ mcp:
21
+ operations: {}
22
+ application: {}
23
+ ```
21
24
 
22
- Enable them in `harper-config.yaml` (or the equivalent `HARPER_SET_CONFIG` process env var):
25
+ - The `operations` profile mounts at `/mcp` on the **operations server** (default port `9925`).
26
+ - The `application` profile mounts at `/mcp` on the **application HTTP server** (default port `9926`).
27
+
28
+ 2. **Configure the operations profile (`mcp.operations`).** This profile wraps Harper's operation catalog.
29
+
30
+ | Key | Type | Default | Purpose |
31
+ | ----------- | ----------------- | --------- | -------------------------------------------------------------------------------- |
32
+ | `mountPath` | `string` | `/mcp` | URL path the endpoint mounts on |
33
+ | `allow` | `array<string>` | See below | Operations exposed as MCP tools (glob or literal); **replaces** the default list |
34
+ | `deny` | `array<string>` | `[]` | Operations filtered out after `allow` is applied |
35
+ | `maxTools` | `integer` (min 1) | `200` | Max tools per `tools/list` response page |
36
+
37
+ Default `allow` list:
38
+
39
+ ```yaml
40
+ allow:
41
+ - describe_*
42
+ - list_*
43
+ - search_*
44
+ - get_job
45
+ - get_status
46
+ - get_analytics
47
+ - get_metrics
48
+ - system_information
49
+ - read_log
50
+ - read_audit_log
51
+ ```
23
52
 
24
- ```yaml
25
- mcp:
26
- application:
27
- mountPath: /mcp # path on the application HTTP port
28
- operations:
29
- mountPath: /mcp # path on the operations port
30
- ```
53
+ Setting `allow` **replaces** the default — it does not merge. To expose destructive operations (e.g. `set_configuration`, `drop_table`), add them explicitly.
31
54
 
32
- A profile is enabled by the **presence** of its config sub-block — there is no separate `enabled` flag, so `mcp: { application: {} }` alone turns the application profile on with defaults. Each profile is independent; enable only what you need. Key per-profile options:
55
+ 3. **Configure the application profile (`mcp.application`).** This profile walks your exported `Resource` classes and generates one MCP tool per implemented REST verb. It accepts all `mcp.operations.*` keys plus:
33
56
 
34
- - `mountPath` — path the endpoint mounts on (default `/mcp`).
35
- - `allow` / `deny` (operations profile only) — glob patterns or literal operation names selecting which operations become tools. The default is a deliberately read-only list; setting `allow` **replaces** it (no merge), so destructive operations like `set_configuration` must be opted in explicitly.
36
- - `maxTools` — page size for `tools/list` responses (default 200); overflow pages via the MCP cursor.
37
- - `rateLimit.*` — see the [Rate Limiting](rate-limiting.md) skill.
38
- - durable quotas — registered in code with `server.setMcpQuotaHandler`, not a config key; see the [Durable Quotas](durable-quotas.md) skill.
57
+ | Key | Type | Default | Purpose |
58
+ | ------------------ | ----------------- | ------- | ------------------------------------------------------------------ |
59
+ | `searchMaxResults` | `integer` (min 1) | `1000` | Hard cap on records a `search_<resource>` tool can return per call |
39
60
 
40
- Version notes: the transport and tool surface shipped across 5.1.x (complete protocol surface — prompts, resources, subscriptions, completions, cancellation, progress — in 5.1.10+). Custom content resources (`mcpResources`) are 5.1.18+. Per-client rate limiting and durable quotas are 5.2.0+.
61
+ 4. **Set rate limits for each profile (`mcp.<profile>.rateLimit.*`).** Token-bucket limits apply per `(session, tool)` pair and per session overall. Exhausted buckets return `result.isError = true` with `kind: "rate_limited"` — not a JSON-RPC error. See [rate-limiting.md](rate-limiting.md) for broader context.
41
62
 
42
- **Verify the version before relying on gated features.** Unsupported config keys (such as the `rateLimit.perClient*` security controls) are **accepted and silently ignored** by older versions — nothing errors, the feature just doesn't run. Check `serverInfo.version` in the `initialize` response (or read `harper://about`) first, and after configuring a limit, prove it denies at least once before trusting it.
63
+ | Key | Type | Default (operations) | Default (application) | Purpose |
64
+ | -------------------- | ----------------- | ----------------------------------- | --------------------- | ----------------------------------------------------------------- |
65
+ | `perToolPerSecond` | `number` (min 0) | `10` | `25` | Sustained per-tool refill rate; `0` disables |
66
+ | `perToolBurst` | `number` (min 0) | `20` | `50` | Burst capacity of the per-tool bucket |
67
+ | `sessionConcurrency` | `integer` (min 0) | `25` | `50` | Max concurrent `tools/call` per session |
68
+ | `sessionPerSecond` | `number` (min 0) | `100` | `200` | Sustained per-session rate across all tools |
69
+ | `perClientPerSecond` | `number` (min 0) | `0` (disabled) | `0` (disabled) | Sustained rate keyed on client identity (5.2.0+) |
70
+ | `perClientBurst` | `number` (min 0) | `perClientPerSecond` floored at `1` | same | Burst capacity of the per-client bucket |
71
+ | `identityHeader` | `string` | unset (socket IP) | unset | Trusted header supplying client identity (e.g. `x-forwarded-for`) |
72
+
73
+ `perClientPerSecond` (5.2.0+) survives session cycling. Only set `identityHeader` when a trusted proxy strips or replaces it on untrusted traffic — a client-controlled header bypasses per-client limits entirely.
74
+
75
+ 5. **Configure session lifecycle (`mcp.session.*`).** These settings apply to both profiles. A dropped session causes the next request bearing that session id to receive HTTP 404; the client is expected to re-`initialize`.
76
+
77
+ | Key | Type | Default | Purpose |
78
+ | -------------------- | ----------------- | ------- | ----------------------------------------------------------------------- |
79
+ | `idleTimeoutSeconds` | `integer` (min 1) | `1800` | Idle window before a session is TTL-evicted from `system.mcp_session` |
80
+ | `allowClientDelete` | `boolean` | `false` | When `true`, accepts client-issued `DELETE /mcp` to terminate a session |
81
+
82
+ 6. **Register a durable quota handler (optional, 5.2.0+).** For cost control that survives restarts and session cycling, register a handler with `server.setMcpQuotaHandler`. The handler runs **after** in-memory buckets admit the call. Keep any backing table unexported (no `@export`). A handler that throws **denies** the call (fail-closed). The handler receives `{ identity, tool, user, profile, sessionId }` and must return `true` to allow or `{ allowed: false, message?, retryAfterSeconds? }` to deny.
83
+
84
+ 7. **Configure Origin validation for browser clients.** MCP validates the `Origin` header to defend against DNS-rebinding attacks. Validation reuses each profile's existing CORS config:
85
+ - No `Origin` header (e.g. `curl`) → always accepted.
86
+ - CORS disabled or `*` allow-list → any `Origin` accepted.
87
+ - CORS enabled with an explicit allow-list → `Origin` not in the list returns `403 Forbidden`.
88
+
89
+ For browser-exposed deployments, enable CORS with an explicit (non-`*`) allow-list on the relevant profile.
43
90
 
44
91
  #### Examples
45
92
 
46
- Minimal application-profile setup for a project with `@export`ed tables:
93
+ **Minimal — both profiles on with all defaults:**
94
+
95
+ ```yaml
96
+ mcp:
97
+ operations: {}
98
+ application: {}
99
+ ```
100
+
101
+ **Production deployment — locked-down operations profile, raised application throughput, graceful logout:**
47
102
 
48
103
  ```yaml
49
- # harper-config.yaml
50
104
  mcp:
105
+ operations:
106
+ allow:
107
+ - describe_all
108
+ - describe_database
109
+ - system_information
110
+ - get_job
111
+ rateLimit:
112
+ perToolPerSecond: 5
113
+ perToolBurst: 10
51
114
  application:
52
- mountPath: /mcp
115
+ searchMaxResults: 500
116
+ rateLimit:
117
+ perToolPerSecond: 50
118
+ perToolBurst: 100
119
+ session:
120
+ idleTimeoutSeconds: 3600
121
+ allowClientDelete: true
53
122
  ```
54
123
 
55
- An MCP client pointed at `http://<host>:9926/mcp` then sees `get_*` / `search_*` / `create_*` / `update_*` / `delete_*` tools for every exported table, RBAC-filtered per authenticated user. See the [Connecting Clients](connecting-clients.md) skill for the handshake.
124
+ **Durable quota handler backed by an internal table:**
125
+
126
+ ```graphql
127
+ # schema.graphql — internal counter, no @export
128
+ type QuotaCounter @table {
129
+ id: ID @primaryKey
130
+ used: Int
131
+ }
132
+ ```
133
+
134
+ ```javascript
135
+ // resources.js
136
+ const DAILY_LIMIT = 100;
137
+
138
+ export class Answerer extends Resource {
139
+ static mcpTools = [{ name: 'answer', description: 'Answer a question', method: 'doAnswer' }];
140
+ async doAnswer(args) {
141
+ return { answered: args?.q ?? '' };
142
+ }
143
+ }
144
+
145
+ server.setMcpQuotaHandler(async ({ identity, tool, user, profile, sessionId }) => {
146
+ if (profile !== 'application') return true;
147
+ const id = identity ?? 'unknown';
148
+ const existing = await tables.QuotaCounter.get(id);
149
+ const used = (existing?.used ?? 0) + 1;
150
+ await tables.QuotaCounter.put({ id, used });
151
+ if (used > DAILY_LIMIT) {
152
+ return { allowed: false, message: 'daily quota reached', retryAfterSeconds: 3600 };
153
+ }
154
+ return true;
155
+ });
156
+ ```
157
+
158
+ #### Notes
159
+
160
+ - The `operations` profile mounts on port `9925`; the `application` profile mounts on port `9926`. These are the default ports — change `mountPath` only if `/mcp` collides with another route.
161
+ - Setting `mcp.operations.allow` **replaces** the default list entirely. Do not add `get_*` as a glob — it exposes `get_configuration` (which can return TLS/S3/auth secrets) and other sensitive operations.
162
+ - `perClientPerSecond` (5.2.0+) is disabled by default (`0`). Enable it to prevent session-cycling abuse.
163
+ - The durable quota handler (5.2.0+) is fail-closed: a thrown error denies the call. Race-safety under concurrent workers is the handler's responsibility — use atomic read-modify-write for production counters.
164
+ - Pass `undefined` to `server.setMcpQuotaHandler` to clear a registered handler. The latest registration wins on component reload.
165
+ - Version notes: the transport and tool surface shipped across 5.1.x (complete protocol surface — prompts, resources, subscriptions, completions, cancellation, progress — in 5.1.10+). Custom content resources (`mcpResources`) are 5.1.18+. Per-client rate limiting (`perClientPerSecond`, `perClientBurst`, `identityHeader`) and the durable quota handler (`server.setMcpQuotaHandler`) are 5.2.0+. **Verify the version before relying on gated features.** Unsupported config keys (such as the `rateLimit.perClient*` security controls) are **accepted and silently ignored** by older versions — nothing errors, the feature just doesn't run. After a session is evicted, the client must re-`initialize` to establish a new session.
166
+ - See [connecting-clients.md](connecting-clients.md) for how to connect MCP clients to these endpoints.
167
+ - See [rate-limiting.md](rate-limiting.md) for broader rate-limiting context.
56
168
 
57
169
  ### 1.2 Connecting Clients
58
170