@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.
- package/.agents/skills/harper-best-practices/AGENTS.md +514 -445
- package/.agents/skills/harper-best-practices/rules/automatic-apis.md +96 -93
- package/.agents/skills/harper-best-practices/rules/deploying-to-harper-fabric.md +84 -58
- package/.agents/skills/harper-best-practices/rules/extending-tables.md +27 -35
- package/.agents/skills/harper-best-practices/rules/handling-binary-data.md +40 -39
- package/.agents/skills/harper-best-practices/rules/querying-rest-apis.md +93 -37
- package/.agents/skills/harper-best-practices/rules/schema-design-tooling.md +68 -66
- package/.agents/skills/harper-best-practices/rules/using-blob-datatype.md +24 -28
- package/.agents/skills/harper-best-practices/rules/v5-upgrade.md +98 -105
- package/.agents/skills/harper-best-practices/rules.manifest.yaml +28 -3
- package/.agents/skills/harper-local-dev/AGENTS.md +197 -0
- package/.agents/skills/harper-local-dev/SKILL.md +65 -0
- package/.agents/skills/harper-local-dev/rules/running-dev-instances-in-worktrees.md +198 -0
- package/.agents/skills/harper-local-dev/rules.manifest.yaml +20 -0
- package/.agents/skills/harper-local-dev/scripts/harper-dev.mjs +345 -0
- package/.agents/skills/harper-mcp/AGENTS.md +138 -26
- package/.agents/skills/harper-mcp/rules/enabling-mcp.md +140 -28
- package/agent/skills/harper-best-practices/AGENTS.md +514 -445
- package/agent/skills/harper-best-practices/rules/automatic-apis.md +96 -93
- package/agent/skills/harper-best-practices/rules/deploying-to-harper-fabric.md +84 -58
- package/agent/skills/harper-best-practices/rules/extending-tables.md +27 -35
- package/agent/skills/harper-best-practices/rules/handling-binary-data.md +40 -39
- package/agent/skills/harper-best-practices/rules/querying-rest-apis.md +93 -37
- package/agent/skills/harper-best-practices/rules/schema-design-tooling.md +68 -66
- package/agent/skills/harper-best-practices/rules/using-blob-datatype.md +24 -28
- package/agent/skills/harper-best-practices/rules/v5-upgrade.md +98 -105
- package/agent/skills/harper-best-practices/rules.manifest.yaml +28 -3
- package/agent/skills/harper-local-dev/AGENTS.md +197 -0
- package/agent/skills/harper-local-dev/SKILL.md +64 -0
- package/agent/skills/harper-local-dev/rules/running-dev-instances-in-worktrees.md +198 -0
- package/agent/skills/harper-local-dev/rules.manifest.yaml +20 -0
- package/agent/skills/harper-local-dev/scripts/harper-dev.mjs +345 -0
- package/agent/skills/harper-mcp/AGENTS.md +138 -26
- package/agent/skills/harper-mcp/rules/enabling-mcp.md +140 -28
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
19
|
+
```yaml
|
|
20
|
+
mcp:
|
|
21
|
+
operations: {}
|
|
22
|
+
application: {}
|
|
23
|
+
```
|
|
21
24
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
+
searchMaxResults: 500
|
|
116
|
+
rateLimit:
|
|
117
|
+
perToolPerSecond: 50
|
|
118
|
+
perToolBurst: 100
|
|
119
|
+
session:
|
|
120
|
+
idleTimeoutSeconds: 3600
|
|
121
|
+
allowClientDelete: true
|
|
53
122
|
```
|
|
54
123
|
|
|
55
|
-
|
|
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
|
|