sparda-mcp 0.5.0 → 0.5.2
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/package.json +6 -3
- package/src/commands/init.js +31 -1
- package/src/generator/express.js +1 -6
- package/src/generator/fastapi.js +5 -7
- package/src/index.js +2 -0
- package/src/probe/express-shim-esm.mjs +27 -0
- package/src/probe/express-shim.cjs +208 -0
- package/src/probe/fastapi-probe.py +146 -0
- package/src/probe/integrate.js +104 -0
- package/src/probe/probe.js +243 -0
- package/src/probe/reconcile.js +160 -0
- package/src/server/condenser.js +3 -2
- package/src/server/confirmation.js +165 -0
- package/src/server/context-carrier.js +322 -0
- package/src/server/crystallize.js +1 -1
- package/src/server/engine.js +579 -0
- package/src/server/idle.js +1 -1
- package/src/server/persistence.js +269 -0
- package/src/server/stdio.js +182 -34
- package/templates/express-router.txt +203 -32
- package/templates/fastapi-router.txt +238 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// server/persistence.js — durable, pluggable state persistence (Chantier 1).
|
|
2
|
+
//
|
|
3
|
+
// Two clearly-separated concerns live here:
|
|
4
|
+
//
|
|
5
|
+
// 1. THE MANIFEST (`sparda.json`) is a LOCAL git artifact: `remove`, `sync`,
|
|
6
|
+
// `doctor` and carry-over all read it from disk, and it is committed. It
|
|
7
|
+
// must never move to a remote store. What it needs is durability — a crash
|
|
8
|
+
// mid-write must never leave it truncated. `atomicWriteFileSync` writes to
|
|
9
|
+
// a temp file, fsyncs it, then renames (rename is atomic on POSIX/NTFS), so
|
|
10
|
+
// a reader sees either the old bytes or the new bytes, never a half-file.
|
|
11
|
+
//
|
|
12
|
+
// 2. PLUGGABLE STATE DRIVERS (Memory / LocalFile / Redis) persist arbitrary,
|
|
13
|
+
// engine-agnostic state by instanceId. This is the seam for the future
|
|
14
|
+
// living-engine state (the bounded brain snapshot) and for multi-node
|
|
15
|
+
// deployments — not for `sparda.json`. Redis is a LAZY import: it is never
|
|
16
|
+
// a hard dependency (hard rule #8 — the 4 pinned deps stay 4), it only
|
|
17
|
+
// loads if a user explicitly selects SPARDA_DRIVER=redis.
|
|
18
|
+
//
|
|
19
|
+
// Host never pays (hard rule #1): nothing here sits on the request path.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import fsp from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// ATOMIC FILE WRITE (the single source of truth — generators + bridge use it)
|
|
29
|
+
// temp file → fsync → rename. fsync is the part the old `atomicWrite` lacked:
|
|
30
|
+
// without it, rename can land before the data is flushed and a power loss
|
|
31
|
+
// leaves a zero-length file. The temp sits next to the target (same fs) so the
|
|
32
|
+
// rename is a real atomic move, not a cross-device copy.
|
|
33
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
// Windows reality: Defender or the search indexer can hold a sub-second lock on the
|
|
36
|
+
// target mid-rename, surfacing as a transient EPERM/EACCES/EBUSY (seen as a ~1-in-N
|
|
37
|
+
// flake in the byte-for-byte fixture tests). A bounded retry with a short, non-
|
|
38
|
+
// spinning backoff clears it. A genuine failure (ENOENT, ENOSPC, EROFS) is NOT a
|
|
39
|
+
// lock — it is surfaced immediately, never retried. Off the hot path (rule #1), so
|
|
40
|
+
// the few ms of blocking on the rare retry never touch a request.
|
|
41
|
+
export const RENAME_MAX_ATTEMPTS = 5;
|
|
42
|
+
const RENAME_BACKOFF_MS = 10;
|
|
43
|
+
const TRANSIENT_LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
44
|
+
|
|
45
|
+
// synchronous, non-spinning wait — blocks this thread for ms without burning CPU.
|
|
46
|
+
function sleepSync(ms) {
|
|
47
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function atomicWriteFileSync(file, content) {
|
|
51
|
+
const tmp = `${file}.sparda-tmp`;
|
|
52
|
+
let fd;
|
|
53
|
+
try {
|
|
54
|
+
fd = fs.openSync(tmp, 'w');
|
|
55
|
+
fs.writeSync(fd, content);
|
|
56
|
+
fs.fsyncSync(fd);
|
|
57
|
+
} finally {
|
|
58
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
for (let attempt = 1; ; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
fs.renameSync(tmp, file);
|
|
63
|
+
return;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!TRANSIENT_LOCK_CODES.has(err.code) || attempt >= RENAME_MAX_ATTEMPTS) {
|
|
66
|
+
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
sleepSync(RENAME_BACKOFF_MS * attempt); // 10, 20, 30, 40 ms — clears a brief AV/indexer lock
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Convenience for the manifest: pretty JSON + trailing newline, written atomically.
|
|
75
|
+
// Mirrors the exact byte shape the generators produce so diffs stay clean.
|
|
76
|
+
export function writeManifestSync(manifestPath, manifest) {
|
|
77
|
+
atomicWriteFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Merge-write a single top-level key into the on-disk manifest without
|
|
81
|
+
// clobbering keys another writer (immune, sparding, labs, semantic) may have
|
|
82
|
+
// touched since. Re-reads, sets one field, writes atomically. Silent on a brief
|
|
83
|
+
// disk hiccup — the in-memory copy is the source of truth and will retry.
|
|
84
|
+
export function mergeManifestKeySync(manifestPath, key, value) {
|
|
85
|
+
try {
|
|
86
|
+
const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
87
|
+
onDisk[key] = value;
|
|
88
|
+
writeManifestSync(manifestPath, onDisk);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// PLUGGABLE STATE DRIVERS (engine-agnostic key/value by instanceId)
|
|
97
|
+
// Interface: save(id, data) load(id) delete(id) listInstances() → all async.
|
|
98
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
// Driver 1 — in-memory. Zero setup, ephemeral. Default for tests and for runs
|
|
101
|
+
// that don't want any state on disk.
|
|
102
|
+
export class MemoryDriver {
|
|
103
|
+
#store = new Map();
|
|
104
|
+
async save(instanceId, data) {
|
|
105
|
+
this.#store.set(instanceId, JSON.parse(JSON.stringify(data)));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
async load(instanceId) {
|
|
109
|
+
return this.#store.get(instanceId) ?? null;
|
|
110
|
+
}
|
|
111
|
+
async delete(instanceId) {
|
|
112
|
+
return this.#store.delete(instanceId);
|
|
113
|
+
}
|
|
114
|
+
async listInstances() {
|
|
115
|
+
return [...this.#store.keys()];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Driver 2 — local file, one JSON per instance under baseDir, written with the
|
|
120
|
+
// same atomic+fsync guarantee as the manifest. Filenames are hashed so an
|
|
121
|
+
// instanceId can be any string without path-injection risk.
|
|
122
|
+
export class LocalFileDriver {
|
|
123
|
+
#baseDir;
|
|
124
|
+
#ttlMs;
|
|
125
|
+
constructor(baseDir = './sparda_state/', ttlDays = 0) {
|
|
126
|
+
this.#baseDir = path.resolve(baseDir);
|
|
127
|
+
this.#ttlMs = ttlDays > 0 ? ttlDays * 86_400_000 : 0;
|
|
128
|
+
fs.mkdirSync(this.#baseDir, { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
#filePath(instanceId) {
|
|
131
|
+
const hash = crypto.createHash('sha256').update(instanceId).digest('hex').slice(0, 16);
|
|
132
|
+
return path.join(this.#baseDir, `sparda_${hash}.json`);
|
|
133
|
+
}
|
|
134
|
+
async save(instanceId, data) {
|
|
135
|
+
try {
|
|
136
|
+
atomicWriteFileSync(this.#filePath(instanceId), JSON.stringify(data, null, 2));
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async load(instanceId) {
|
|
143
|
+
const target = this.#filePath(instanceId);
|
|
144
|
+
try {
|
|
145
|
+
if (this.#ttlMs > 0) {
|
|
146
|
+
const stat = await fsp.stat(target);
|
|
147
|
+
if (Date.now() - stat.mtimeMs > this.#ttlMs) {
|
|
148
|
+
await fsp.unlink(target).catch(() => {});
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return JSON.parse(await fsp.readFile(target, 'utf8'));
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async delete(instanceId) {
|
|
158
|
+
try {
|
|
159
|
+
await fsp.unlink(this.#filePath(instanceId));
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async listInstances() {
|
|
166
|
+
try {
|
|
167
|
+
const files = await fsp.readdir(this.#baseDir);
|
|
168
|
+
return files
|
|
169
|
+
.filter((f) => f.startsWith('sparda_') && f.endsWith('.json'))
|
|
170
|
+
.map((f) => f.slice('sparda_'.length, -'.json'.length));
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Driver 3 — Redis, for distributed/multi-pod nodes. ioredis is imported lazily
|
|
178
|
+
// and is NOT a package dependency: selecting this driver without ioredis
|
|
179
|
+
// installed throws a clear, actionable error rather than failing at import.
|
|
180
|
+
export class RedisDriver {
|
|
181
|
+
#prefix;
|
|
182
|
+
#ttl;
|
|
183
|
+
#client = null;
|
|
184
|
+
#ready;
|
|
185
|
+
constructor(options = {}) {
|
|
186
|
+
this.#prefix = options.keyPrefix ?? 'sparda:';
|
|
187
|
+
this.#ttl = options.ttlSeconds ?? 0;
|
|
188
|
+
this.#ready = this.#init(options);
|
|
189
|
+
}
|
|
190
|
+
async #init(options) {
|
|
191
|
+
let Redis;
|
|
192
|
+
try {
|
|
193
|
+
({ default: Redis } = await import('ioredis'));
|
|
194
|
+
} catch {
|
|
195
|
+
throw Object.assign(
|
|
196
|
+
new Error('RedisDriver requires the optional "ioredis" package.'),
|
|
197
|
+
{ code: 'USER', hint: 'Run `npm install ioredis`, or use the default file driver.' },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
this.#client = new Redis({
|
|
201
|
+
host: options.host ?? 'localhost',
|
|
202
|
+
port: options.port ?? 6379,
|
|
203
|
+
password: options.password,
|
|
204
|
+
db: options.db ?? 0,
|
|
205
|
+
tls: options.tls ? {} : undefined,
|
|
206
|
+
lazyConnect: true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
#key(instanceId) {
|
|
210
|
+
return `${this.#prefix}${instanceId}`;
|
|
211
|
+
}
|
|
212
|
+
async save(instanceId, data) {
|
|
213
|
+
try {
|
|
214
|
+
await this.#ready;
|
|
215
|
+
const payload = JSON.stringify(data);
|
|
216
|
+
if (this.#ttl > 0) await this.#client.setex(this.#key(instanceId), this.#ttl, payload);
|
|
217
|
+
else await this.#client.set(this.#key(instanceId), payload);
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async load(instanceId) {
|
|
224
|
+
try {
|
|
225
|
+
await this.#ready;
|
|
226
|
+
const raw = await this.#client.get(this.#key(instanceId));
|
|
227
|
+
return raw ? JSON.parse(raw) : null;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async delete(instanceId) {
|
|
233
|
+
try {
|
|
234
|
+
await this.#ready;
|
|
235
|
+
return (await this.#client.del(this.#key(instanceId))) > 0;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async listInstances() {
|
|
241
|
+
try {
|
|
242
|
+
await this.#ready;
|
|
243
|
+
const keys = await this.#client.keys(`${this.#prefix}*`);
|
|
244
|
+
return keys.map((k) => k.slice(this.#prefix.length));
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Factory from env. Default is the local file driver — survives restarts with
|
|
252
|
+
// zero setup, no new dependency. `memory` for tests, `redis` opts into the lazy
|
|
253
|
+
// dependency. Unknown values fall back to file (fail safe, not silent crash).
|
|
254
|
+
export function createStateDriverFromEnv(env = process.env) {
|
|
255
|
+
switch ((env.SPARDA_DRIVER ?? 'file').toLowerCase()) {
|
|
256
|
+
case 'memory':
|
|
257
|
+
return new MemoryDriver();
|
|
258
|
+
case 'redis':
|
|
259
|
+
return new RedisDriver({
|
|
260
|
+
host: env.SPARDA_REDIS_HOST ?? 'localhost',
|
|
261
|
+
port: env.SPARDA_REDIS_PORT ? Number(env.SPARDA_REDIS_PORT) : 6379,
|
|
262
|
+
password: env.SPARDA_REDIS_PASSWORD,
|
|
263
|
+
keyPrefix: env.SPARDA_REDIS_PREFIX ?? 'sparda:',
|
|
264
|
+
});
|
|
265
|
+
case 'file':
|
|
266
|
+
default:
|
|
267
|
+
return new LocalFileDriver(env.SPARDA_STATE_DIR ?? './sparda_state/');
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/server/stdio.js
CHANGED
|
@@ -12,6 +12,10 @@ import { sanitizeDescription } from '../security/sanitize.js';
|
|
|
12
12
|
import { createIdleHarvester } from './idle.js';
|
|
13
13
|
import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
|
|
14
14
|
import { eligibleForCrystallization, fallbackComposite, normalizeCompositeName, compositeSchema, runComposite } from './crystallize.js';
|
|
15
|
+
import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
|
|
16
|
+
import { createSpardaEngine } from './engine.js';
|
|
17
|
+
import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
|
|
18
|
+
import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
|
|
15
19
|
|
|
16
20
|
const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
|
|
17
21
|
// the sampling budgets below are also what the recycling gauge counts as "avoided"
|
|
@@ -35,16 +39,29 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
35
39
|
if (!key) {
|
|
36
40
|
throw Object.assign(new Error('localKey missing from sparda.json.'), { code: 'USER', hint: 'Re-run `npx sparda-mcp init` to regenerate it.' });
|
|
37
41
|
}
|
|
38
|
-
|
|
42
|
+
|
|
43
|
+
// Brief #4 — tenant context carrier. Resolved ONCE, here, from operator-pinned
|
|
44
|
+
// sources (CLI --context > env SPARDA_CONTEXT_* > sparda.json names the headers).
|
|
45
|
+
// The AI is the adversary and can forge anything it sends; pinning at launch (not
|
|
46
|
+
// per call) is the only scope it cannot choose. Throws USER on CRLF/over-bound so
|
|
47
|
+
// a malformed scope stops the bridge instead of degrading to "no scope". Absent
|
|
48
|
+
// config → frozen empty map → injectContext is a no-op (byte-identical forwarding).
|
|
49
|
+
const ctx = resolveContext({ argv: process.argv, env: process.env, config: manifest.contextPropagation ?? null });
|
|
50
|
+
const ctxFp = fingerprintContext(ctx); // value-free: names + 8-hex SHA-256 only (R2/R7)
|
|
51
|
+
if (Object.keys(ctxFp).length > 0) {
|
|
52
|
+
console.error(`[sparda] context carrier active (forwarded verbatim on every host call): ${JSON.stringify(ctxFp)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const base = await waitForHost(port, key, framework, ctx);
|
|
39
56
|
|
|
40
57
|
// full tool specs live in the generated router; fetch them (single source of truth)
|
|
41
|
-
const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers:
|
|
58
|
+
const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers: hostHeaders(key, ctx) })).json();
|
|
42
59
|
|
|
43
60
|
const enabled = () => Object.entries(toolSpecs).filter(([, t]) => t.enabled);
|
|
44
61
|
const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
|
|
45
62
|
|
|
46
63
|
const server = new Server(
|
|
47
|
-
{ name: `sparda-${path.basename(cwd)}`, version: '0.5.
|
|
64
|
+
{ name: `sparda-${path.basename(cwd)}`, version: '0.5.2' },
|
|
48
65
|
{ capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, logging: {} } },
|
|
49
66
|
);
|
|
50
67
|
|
|
@@ -55,6 +72,16 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
55
72
|
|
|
56
73
|
// R4.4: every internal organ works only when the event loop is quiet
|
|
57
74
|
const harvester = createIdleHarvester();
|
|
75
|
+
// engine (Bloc B, slice 1 — "cerveau de stabilité"): a passive per-tool read of
|
|
76
|
+
// which response fields stay put vs move. Fed off the hot path via the harvester,
|
|
77
|
+
// holds field fingerprints only (never values, ADR-014), runtime-only — no
|
|
78
|
+
// carry-over, same posture as the router's purity detector.
|
|
79
|
+
const engine = createSpardaEngine();
|
|
80
|
+
// R4.3 kill-switch (decision A, ADR-020): the flywheel SERVES proven-stable reads
|
|
81
|
+
// from memory by default. SPARDA_FLYWHEEL=off keeps every organ learning but stops
|
|
82
|
+
// serving, so a suspect cache can be cut without losing the brain. Gated here at the
|
|
83
|
+
// bridge; the engine itself stays env-free.
|
|
84
|
+
const flywheelServes = process.env.SPARDA_FLYWHEEL !== 'off';
|
|
58
85
|
// R2.1 (Labs, default OFF): record the current of calls, detect circuits;
|
|
59
86
|
// R2.2: at the observation threshold a circuit crystallizes into a composite tool
|
|
60
87
|
const composites = new Map(); // composite tool name -> { sig, circuit }
|
|
@@ -163,6 +190,17 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
163
190
|
description: 'Call this FIRST. Returns the full living context of this app: every tool with its description, known workflows, runtime telemetry (per-tool calls/errors/latency), quarantined tools, and the immune memory of past diagnosed failures. Lets any AI session resume exactly where the previous one stopped.',
|
|
164
191
|
inputSchema: { type: 'object', properties: {} },
|
|
165
192
|
},
|
|
193
|
+
{
|
|
194
|
+
name: 'sparda_confirm',
|
|
195
|
+
description: 'Confirms a pending write or delete operation gated by human-in-the-loop policies using its confirmation token.',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: {
|
|
199
|
+
token: { type: 'string', description: 'The confirmation token returned by the gated invoke response.' }
|
|
200
|
+
},
|
|
201
|
+
required: ['token']
|
|
202
|
+
}
|
|
203
|
+
},
|
|
166
204
|
],
|
|
167
205
|
}));
|
|
168
206
|
|
|
@@ -204,7 +242,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
204
242
|
}, null, 2));
|
|
205
243
|
}
|
|
206
244
|
if (name === 'sparda_get_context') {
|
|
207
|
-
const headers =
|
|
245
|
+
const headers = hostHeaders(key, ctx);
|
|
208
246
|
const [stats, events] = await Promise.all([
|
|
209
247
|
fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
210
248
|
fetch(`${base}/mcp/events?since=0`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
@@ -213,6 +251,8 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
213
251
|
// the first hit paid the diagnosis, every later one was served from memory
|
|
214
252
|
const antibodyHits = Object.values(manifest.immune?.antibodies ?? {})
|
|
215
253
|
.reduce((n, a) => n + Math.max(0, (a.hits ?? 1) - 1), 0);
|
|
254
|
+
const behavior = engine.snapshot();
|
|
255
|
+
const fly = behavior.flywheel.stats;
|
|
216
256
|
return text(JSON.stringify({
|
|
217
257
|
project: path.basename(cwd),
|
|
218
258
|
framework: manifest.framework,
|
|
@@ -224,14 +264,19 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
224
264
|
immuneMemory: manifest.immune?.antibodies ?? {},
|
|
225
265
|
recycling: {
|
|
226
266
|
compute: stats?.recycle ?? null,
|
|
267
|
+
// R4.3: host calls the flywheel answered from its own RAM — a category the
|
|
268
|
+
// router cannot count, because the request never reached it. armed = reads
|
|
269
|
+
// proven stable enough to serve right now.
|
|
270
|
+
flywheel: { servedFromMemory: fly.served, armed: fly.ready },
|
|
227
271
|
intelligence: {
|
|
228
272
|
session: { samplingAvoided: intel.samplingAvoided, tokensAvoidedEst: intel.tokensAvoidedEst },
|
|
229
273
|
lifetime: { antibodyHits, tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS },
|
|
230
274
|
},
|
|
231
275
|
},
|
|
232
276
|
sparding: manifest.sparding ?? {},
|
|
277
|
+
behavior,
|
|
233
278
|
labs: { recordSequences: Boolean(recorder), compositeTools: [...composites.keys()], circuits: manifest.labs?.circuits ?? {} },
|
|
234
|
-
hint: 'runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens. recycling measures how much compute/intelligence was served from SPARDA\'s own memory instead of being paid again. runtime.purity classifies each route by observation: pure = same args keep returning the same response (recyclable), volatile = it changed, erasing = writes. labs.circuits are observed call sequences where one tool\'s output fed the next one\'s input.',
|
|
279
|
+
hint: 'runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens. recycling measures how much compute/intelligence was served from SPARDA\'s own memory instead of being paid again. runtime.purity classifies each route by observation: pure = same args keep returning the same response (recyclable), volatile = it changed, erasing = writes. labs.circuits are observed call sequences where one tool\'s output fed the next one\'s input. behavior.stability is the engine\'s passive read of each tool across this session: stable lists response fields that never changed (predictable — future recycling candidates), volatile lists fields that moved; calls is how many responses were observed. behavior.rhythm flags tools called on a steady cadence (periodMs = the beat, confidence 0-1, nextEstimate = when the next call is expected) — a regular rhythm plus a stable result is the textbook pre-fetch candidate. behavior.myelin learns habitual tool succession: an edge "A-->B" means B tends to be called right after A; strength (0-10) grows with every traversal and myelinated edges (strength>=3) are entrenched habits — succession candidates the condenser misses because no data flows between the two tools. behavior.dependencies is the engine\'s map of what the other observers cannot see: invariants are response fields that stayed conserved (>=85% over >=5 reads) even while writes hit the app — true constants, the safest thing to cache hard; ghosts are hidden couplings where a write tool reliably MOVES some unrelated read (writeTool affects a different read tool, correlation 0-1), discovered purely by observation — no data flows between them and they need not be adjacent, yet the write keeps perturbing that read. behavior.flywheel is Bloc B acting on all of the above: once a read has returned the identical response >=3 times for the same arguments it is served straight from memory and the host call is skipped entirely (R4.3), with ready = how many reads are armed to serve right now; recycling.flywheel.servedFromMemory counts how many host calls were already answered from RAM this session — the one recycling category the router cannot see, because the request never reached it (SPARDA_FLYWHEEL=off stops serving while every organ keeps learning).',
|
|
235
280
|
}, null, 2));
|
|
236
281
|
}
|
|
237
282
|
if (name === 'sparda_list_disabled_tools') {
|
|
@@ -239,12 +284,33 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
239
284
|
? `Disabled (write-safety):\n${disabled().map(([n, t]) => `- ${n} (${t.method} ${t.path})`).join('\n')}\n\nTo enable: set "enabled": true in sparda.json, then re-run \`npx sparda-mcp init\` and restart this bridge.`
|
|
240
285
|
: 'No disabled tools.');
|
|
241
286
|
}
|
|
287
|
+
if (name === 'sparda_confirm') {
|
|
288
|
+
const token = args.token;
|
|
289
|
+
if (typeof token !== 'string' || !token) {
|
|
290
|
+
return { content: [{ type: 'text', text: 'Error: missing or invalid confirmation token.' }], isError: true };
|
|
291
|
+
}
|
|
292
|
+
// SIGNAL 2 (Brief #1): the AI holds the token (Signal 1, reachable over stdio) but the
|
|
293
|
+
// write proceeds only if a human approved out-of-band — an OS-dialog click, or a prior
|
|
294
|
+
// native-elicitation accept. Neither channel is reachable by the AI, so a self-issued
|
|
295
|
+
// confirm can no longer pass. Necessary-but-not-sufficient by construction.
|
|
296
|
+
const gate = confirmWrite(token);
|
|
297
|
+
if (!gate.ok) {
|
|
298
|
+
return { content: [{ type: 'text', text: signal2Denial(gate.reason) }], isError: true };
|
|
299
|
+
}
|
|
300
|
+
const payload = await confirmInvoke(base, key, token, ctx);
|
|
301
|
+
if (payload === null) {
|
|
302
|
+
return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
|
|
303
|
+
}
|
|
304
|
+
const pretty = JSON.stringify(payload, null, 2);
|
|
305
|
+
const isError = payload.upstreamStatus !== undefined ? payload.upstreamStatus >= 400 : Boolean(payload.error);
|
|
306
|
+
return { content: [{ type: 'text', text: pretty }], isError };
|
|
307
|
+
}
|
|
242
308
|
|
|
243
309
|
// composite tools run their whole chain (GET-only by construction — no
|
|
244
310
|
// write confirmation to bypass), auto-feeding linked args between steps
|
|
245
311
|
const comp = composites.get(name);
|
|
246
312
|
if (comp) {
|
|
247
|
-
const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a) });
|
|
313
|
+
const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a, ctx) });
|
|
248
314
|
const pretty = JSON.stringify(result, null, 2);
|
|
249
315
|
return {
|
|
250
316
|
content: [{ type: 'text', text: pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty }],
|
|
@@ -255,7 +321,10 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
255
321
|
const spec = toolSpecs[name];
|
|
256
322
|
const isWrite = spec && spec.method !== 'GET';
|
|
257
323
|
|
|
258
|
-
// human-in-the-loop: confirm writes natively in the client UI when supported
|
|
324
|
+
// human-in-the-loop, channel 1: confirm writes natively in the client UI when supported.
|
|
325
|
+
// A native accept is a real out-of-band human yes — it pre-approves Signal 2 below, so the
|
|
326
|
+
// host's confirm round-trip never prompts the operator a second time.
|
|
327
|
+
let elicitationApproved = false;
|
|
259
328
|
if (isWrite && server.getClientCapabilities()?.elicitation) {
|
|
260
329
|
const answer = await server.elicitInput({
|
|
261
330
|
message: `SPARDA: allow ${spec.method} ${spec.path}? This is a write operation on your live app.`,
|
|
@@ -275,9 +344,26 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
275
344
|
recordSparding(manifestPath, manifest, name, mockProof);
|
|
276
345
|
return { content: [{ type: 'text', text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.` }], isError: true };
|
|
277
346
|
}
|
|
347
|
+
elicitationApproved = true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Bloc B flywheel (R4.3): the first organ that ACTS. For a read proven stable —
|
|
351
|
+
// the same response >=3x for these exact args, within TTL — serve straight from the
|
|
352
|
+
// engine's RAM and never make the host call. Writes always fall through to the host
|
|
353
|
+
// (and still pass write-confirmation above), so hard rule #3 is untouched. The served
|
|
354
|
+
// `data` is byte-identical to what the host returned; servedByFlywheel marks the
|
|
355
|
+
// envelope so a hit is observable. Not re-observed (that would feed the cache itself).
|
|
356
|
+
if (spec && !isWrite && flywheelServes) {
|
|
357
|
+
const cached = engine.preCall(name, args);
|
|
358
|
+
if (cached.hit) {
|
|
359
|
+
const body = { data: cached.value, upstreamStatus: 200, servedByFlywheel: true };
|
|
360
|
+
const pretty = JSON.stringify(body, null, 2);
|
|
361
|
+
const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty;
|
|
362
|
+
return { content: [{ type: 'text', text: truncated }], isError: false };
|
|
363
|
+
}
|
|
278
364
|
}
|
|
279
365
|
|
|
280
|
-
const payload = await invoke(base, key, name, args);
|
|
366
|
+
const payload = await invoke(base, key, name, args, ctx);
|
|
281
367
|
if (payload === null) {
|
|
282
368
|
const mockProof = {
|
|
283
369
|
version: 'sparding-proof/v0.1',
|
|
@@ -289,6 +375,16 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
289
375
|
return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
|
|
290
376
|
}
|
|
291
377
|
|
|
378
|
+
// human-in-the-loop, channel 2 (Brief #1): the host gated this write and minted a single-use
|
|
379
|
+
// confirm token. Arm Signal 2 NOW, keyed by that token. Elicitation clients already have a
|
|
380
|
+
// human yes → pre-approve (no second prompt). Everyone else gets an OS dialog (fires async,
|
|
381
|
+
// R1) that `sparda_confirm` will require before the write can run. This is what closes the
|
|
382
|
+
// forgeable-confirmation hole on clients without elicitation.
|
|
383
|
+
if (isWrite && payload.status === 'awaiting_confirmation' && typeof payload.confirm === 'string') {
|
|
384
|
+
if (elicitationApproved) preapproveWrite(payload.confirm);
|
|
385
|
+
else initiateWrite({ token: payload.confirm, label: `${spec.method} ${spec.path}` });
|
|
386
|
+
}
|
|
387
|
+
|
|
292
388
|
const proofForRecord = payload.spardingProof ? { ...payload.spardingProof } : { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
|
|
293
389
|
if (payload.upstreamStatus !== undefined && payload.upstreamStatus >= 400) {
|
|
294
390
|
proofForRecord.isExecutionError = true;
|
|
@@ -299,18 +395,33 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
299
395
|
}
|
|
300
396
|
recordSparding(manifestPath, manifest, name, proofForRecord);
|
|
301
397
|
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
if (
|
|
305
|
-
|
|
398
|
+
// successful AI-driven reads feed SPARDA's passive observers, both off the hot
|
|
399
|
+
// path (idle harvester). Internal read-backs and failures are not workflow steps.
|
|
400
|
+
if (payload.upstreamStatus !== undefined && payload.upstreamStatus < 400) {
|
|
401
|
+
// engine: stability + rhythm + myelin (Blocs A/B) and the dependency map
|
|
402
|
+
// (Bloc D — invariants conserved across writes, ghost write->read couplings).
|
|
403
|
+
// isWrite lets Bloc D tell a state read from a mutation. Capture the call time
|
|
404
|
+
// here on the hot path; classify in idle. (The proof-after-write read-back below
|
|
405
|
+
// is internal, not an AI workflow step, so it is deliberately not observed.)
|
|
406
|
+
const observedAt = Date.now();
|
|
407
|
+
harvester.enqueue(() => engine.observe(name, payload.data, observedAt, isWrite, args));
|
|
408
|
+
// condenser tap (Labs, opt-in): detect circuits where one output feeds the next
|
|
409
|
+
if (recorder) recorder.record(name, args, payload.data);
|
|
306
410
|
}
|
|
307
411
|
|
|
308
412
|
// proof-after-write: re-read the same path so the AI sees the effect of its write
|
|
309
413
|
let proof = null;
|
|
310
414
|
if (isWrite && payload.upstreamStatus < 400) {
|
|
415
|
+
// flywheel coherence (5b): this write just moved whatever a GET on the same path
|
|
416
|
+
// returns, so drop that GET's cached answer NOW — synchronous, before the next read
|
|
417
|
+
// can be served stale. The engine purges by learned ghost couplings in idle; this
|
|
418
|
+
// covers the structural same-path case it can't see (no path info in the engine).
|
|
419
|
+
for (const [n, t] of Object.entries(toolSpecs)) {
|
|
420
|
+
if (t.method === 'GET' && t.path === spec.path) engine.invalidateCache(n);
|
|
421
|
+
}
|
|
311
422
|
const getter = Object.entries(toolSpecs).find(([, t]) => t.enabled && t.method === 'GET' && t.path === spec.path);
|
|
312
423
|
if (getter) {
|
|
313
|
-
const after = await invoke(base, key, getter[0], pickPathArgs(spec, args));
|
|
424
|
+
const after = await invoke(base, key, getter[0], pickPathArgs(spec, args), ctx);
|
|
314
425
|
if (after && after.upstreamStatus < 400) proof = { readBack: getter[0], state: after.data };
|
|
315
426
|
}
|
|
316
427
|
}
|
|
@@ -327,7 +438,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
327
438
|
server.oninitialized = () => {
|
|
328
439
|
// a session resumed on a cached semantic pass skipped the enrichment sampling call
|
|
329
440
|
if (manifest.semantic) { intel.samplingAvoided += 1; intel.tokensAvoidedEst += SEMANTIC_TOKENS; }
|
|
330
|
-
pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel);
|
|
441
|
+
pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel, ctx);
|
|
331
442
|
runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) => console.error(`[sparda] semantic pass skipped: ${e.message}`));
|
|
332
443
|
};
|
|
333
444
|
server.onclose = () => {
|
|
@@ -341,11 +452,22 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
341
452
|
console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`);
|
|
342
453
|
}
|
|
343
454
|
|
|
344
|
-
|
|
455
|
+
// One place to build the outbound header set for any bridge→host call. The
|
|
456
|
+
// operator-pinned tenant context (Brief #4) is copied on LAST so it is always
|
|
457
|
+
// present and can never be shadowed by a per-call header — the AI cannot forge
|
|
458
|
+
// the scope. When context is off, injectContext is a no-op and the headers are
|
|
459
|
+
// byte-identical to before.
|
|
460
|
+
function hostHeaders(key, ctx, extra) {
|
|
461
|
+
const h = { 'x-sparda-key': key, ...(extra ?? {}) };
|
|
462
|
+
injectContext(h, ctx);
|
|
463
|
+
return h;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function invoke(base, key, tool, args, ctx) {
|
|
345
467
|
try {
|
|
346
468
|
const res = await fetch(`${base}/mcp/invoke`, {
|
|
347
469
|
method: 'POST',
|
|
348
|
-
headers: { 'content-type': 'application/json'
|
|
470
|
+
headers: hostHeaders(key, ctx, { 'content-type': 'application/json' }),
|
|
349
471
|
body: JSON.stringify({ tool, args }),
|
|
350
472
|
signal: AbortSignal.timeout(30_000),
|
|
351
473
|
});
|
|
@@ -355,6 +477,20 @@ async function invoke(base, key, tool, args) {
|
|
|
355
477
|
}
|
|
356
478
|
}
|
|
357
479
|
|
|
480
|
+
async function confirmInvoke(base, key, token, ctx) {
|
|
481
|
+
try {
|
|
482
|
+
const res = await fetch(`${base}/mcp/invoke/confirm`, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: hostHeaders(key, ctx, { 'content-type': 'application/json' }),
|
|
485
|
+
body: JSON.stringify({ confirm: token }),
|
|
486
|
+
signal: AbortSignal.timeout(30_000),
|
|
487
|
+
});
|
|
488
|
+
return await res.json().catch(() => ({ upstreamStatus: res.status, error: 'non-JSON response from host' }));
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
358
494
|
// keep only the path params of the original call so the read-back targets the same resource
|
|
359
495
|
function pickPathArgs(spec, args) {
|
|
360
496
|
const out = {};
|
|
@@ -365,11 +501,11 @@ function pickPathArgs(spec, args) {
|
|
|
365
501
|
// live error feed: host app errors reach the AI as MCP logging notifications.
|
|
366
502
|
// immune memory: known failure signatures carry their cached diagnosis (zero tokens);
|
|
367
503
|
// new signatures wake the client's LLM once, and the antibody is stored in sparda.json.
|
|
368
|
-
function startEventPolling(server, base, key, manifest, manifestPath, intel) {
|
|
504
|
+
function startEventPolling(server, base, key, manifest, manifestPath, intel, ctx) {
|
|
369
505
|
let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
|
|
370
506
|
const timer = setInterval(async () => {
|
|
371
507
|
try {
|
|
372
|
-
const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, { headers:
|
|
508
|
+
const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, { headers: hostHeaders(key, ctx), signal: AbortSignal.timeout(2000) });
|
|
373
509
|
if (!r.ok) return;
|
|
374
510
|
const { seq, events } = await r.json();
|
|
375
511
|
if (lastSeq === null) { lastSeq = seq; return; }
|
|
@@ -433,11 +569,10 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
|
|
|
433
569
|
}
|
|
434
570
|
|
|
435
571
|
function persistImmune(manifestPath, manifest) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
} catch { /* disk briefly unavailable — the antibody stays in memory */ }
|
|
572
|
+
// merge-write (atomic + fsync): re-read so we never clobber sparding/labs/
|
|
573
|
+
// semantic written by another path; on a brief disk hiccup the antibody
|
|
574
|
+
// stays in memory and the next write retries.
|
|
575
|
+
mergeManifestKeySync(manifestPath, 'immune', manifest.immune);
|
|
441
576
|
}
|
|
442
577
|
|
|
443
578
|
function recordSparding(manifestPath, manifest, tool, proof) {
|
|
@@ -493,18 +628,16 @@ function recordSparding(manifestPath, manifest, tool, proof) {
|
|
|
493
628
|
}
|
|
494
629
|
|
|
495
630
|
function persistSparding(manifestPath, manifest) {
|
|
496
|
-
|
|
497
|
-
const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
498
|
-
onDisk.sparding = manifest.sparding;
|
|
499
|
-
fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
|
|
500
|
-
} catch { /* disk briefly unavailable */ }
|
|
631
|
+
mergeManifestKeySync(manifestPath, 'sparding', manifest.sparding);
|
|
501
632
|
}
|
|
502
633
|
|
|
503
634
|
function persistLabs(manifestPath, manifest) {
|
|
635
|
+
// value depends on the re-read (merge into labs, keep other labs fields), so
|
|
636
|
+
// this can't use mergeManifestKeySync — but the write is still atomic+fsync.
|
|
504
637
|
try {
|
|
505
638
|
const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
506
639
|
onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
|
|
507
|
-
|
|
640
|
+
writeManifestSync(manifestPath, onDisk);
|
|
508
641
|
} catch { /* disk briefly unavailable — the circuit stays in memory */ }
|
|
509
642
|
}
|
|
510
643
|
|
|
@@ -546,9 +679,7 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
|
|
|
546
679
|
.filter((w) => w.steps.length > 0);
|
|
547
680
|
|
|
548
681
|
manifest.semantic = { enrichedAt: new Date().toISOString(), source: 'mcp-sampling', descriptions, workflows };
|
|
549
|
-
|
|
550
|
-
onDisk.semantic = manifest.semantic;
|
|
551
|
-
fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
|
|
682
|
+
mergeManifestKeySync(manifestPath, 'semantic', manifest.semantic);
|
|
552
683
|
|
|
553
684
|
await server.sendToolListChanged().catch(() => {});
|
|
554
685
|
await server.sendPromptListChanged().catch(() => {});
|
|
@@ -577,12 +708,29 @@ function schemaFor(t) {
|
|
|
577
708
|
|
|
578
709
|
function text(t) { return { content: [{ type: 'text', text: t }] }; }
|
|
579
710
|
|
|
580
|
-
|
|
711
|
+
// AI-facing message when the Signal-2 gate (Brief #1) refuses a `sparda_confirm`. Each reason
|
|
712
|
+
// tells the model what to do next without implying it can approve the write itself.
|
|
713
|
+
function signal2Denial(reason) {
|
|
714
|
+
switch (reason) {
|
|
715
|
+
case 'awaiting_human':
|
|
716
|
+
return 'Awaiting human approval. A confirmation dialog is open on the host machine — the operator must click Allow. You cannot approve this yourself. Call sparda_confirm again with the same token once the operator has approved.';
|
|
717
|
+
case 'human_denied':
|
|
718
|
+
return 'The operator DENIED this write at the host confirmation dialog. It was NOT executed. Do not retry this token.';
|
|
719
|
+
case 'unknown_token':
|
|
720
|
+
return 'Unknown or already-used confirmation token. Re-issue the write to mint a fresh token (which opens a new human approval dialog).';
|
|
721
|
+
case 'expired':
|
|
722
|
+
return 'The confirmation expired before a human approved it. Re-issue the write to try again.';
|
|
723
|
+
default:
|
|
724
|
+
return 'Confirmation could not be validated. Re-issue the write to mint a fresh token.';
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function waitForHost(port, key, framework, ctx) {
|
|
581
729
|
const hosts = ['127.0.0.1', 'localhost'];
|
|
582
730
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
583
731
|
for (const h of hosts) {
|
|
584
732
|
try {
|
|
585
|
-
const r = await fetch(`http://${h}:${port}/mcp/tools`, { headers:
|
|
733
|
+
const r = await fetch(`http://${h}:${port}/mcp/tools`, { headers: hostHeaders(key, ctx), signal: AbortSignal.timeout(1500) });
|
|
586
734
|
if (r.ok) return `http://${h}:${port}`;
|
|
587
735
|
if (r.status === 401) throw Object.assign(new Error('Host app reachable but key mismatch — re-run `npx sparda-mcp init`.'), { code: 'USER' });
|
|
588
736
|
} catch (e) { if (e.code === 'USER') throw e; }
|