sparda-mcp 0.8.0 → 0.8.1
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/README.md +3 -0
- package/SKILL.md +12 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +3 -1
- package/src/commands/evolve.js +4 -2
- package/src/commands/negentropy.js +12 -11
- package/src/commands/report.js +4 -2
- package/src/commands/twin.js +26 -7
- package/src/generator/express.js +15 -4
- package/src/generator/fastapi.js +11 -4
- package/src/generator/manifest.js +34 -0
- package/src/generator/nextjs.js +11 -4
- package/src/server/stdio.js +4 -3
- package/templates/express-router.txt +18 -1
- package/templates/fastapi-router.txt +26 -1
- package/templates/nextjs-router.txt +18 -1
package/README.md
CHANGED
|
@@ -107,6 +107,9 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
|
|
|
107
107
|
4. Suspicious docstrings are sanitized before they ever reach the AI (prompt-injection defense).
|
|
108
108
|
5. `npx sparda-mcp doctor --app` audits your codebase for drift: it detects stale tools (IA seeing ghosts), unsynced routes, schema drift via fingerprints, and zombie configurations. High severity issues trigger a non-zero exit code for your CI pipeline.
|
|
109
109
|
6. `npx sparda-mcp seed export/import` lets you package and share your app's "genome" (semantic memory, workflows, antibodies) securely, transferring immune memory between environments or across similar stacks with zero data leak.
|
|
110
|
+
7. `npx sparda-mcp twin` starts a safe, simulated mock server of your backend on the original port. It serves GET calls from learned exemplars (observed response shapes & mock data) and returns simulated 202 writes without ever touching your real database or production APIs. Learn exemplars by running `npx sparda-mcp twin --learn`.
|
|
111
|
+
8. `npx sparda-mcp grammar` maps the graph of valid sequences of tool calls (observed circuits and candidate hypotheses) to prevent LLM hallucination of routes.
|
|
112
|
+
9. `npx sparda-mcp evolve` mutates candidate chains and tests them against the twin in-memory, promoting successful chains to evolved workflow suggestions.
|
|
110
113
|
|
|
111
114
|
## What SPARDA gives your AI
|
|
112
115
|
|
package/SKILL.md
CHANGED
|
@@ -84,6 +84,16 @@ adapt — don't blindly retry the same call.
|
|
|
84
84
|
**5. Latency anomalies.** A call far slower than a tool's own baseline surfaces as
|
|
85
85
|
an `immune` event in `/mcp/events`. Treat it as a hint to back off or warn the user.
|
|
86
86
|
|
|
87
|
+
**6. Twin Simulation Mode — practice safely on a clone.**
|
|
88
|
+
When `/mcp/stats` or `sparda_get_context.runtime` contains `"twin": true`, you are connected to a safe, in-memory mock clone of the application.
|
|
89
|
+
- All GET reads return learned exemplars (observed response shapes and mock values).
|
|
90
|
+
- All write tools return simulated `202` echoes but do not write to database or external APIs.
|
|
91
|
+
- Use this twin mode to practice multi-step workflows, debug tool sequences, and test your plans without touching the live production backend.
|
|
92
|
+
|
|
93
|
+
**7. Grammar & Evolution — discover optimal workflows.**
|
|
94
|
+
- You can query or contribute to the app's grammar (`.sparda/grammar.json`). The grammar maps valid sequences of tool calls (edges).
|
|
95
|
+
- Running `sparda evolve` mutates and runs candidate chains against the twin. The successful evolved sequences are suggested as mid-session workflows.
|
|
96
|
+
|
|
87
97
|
## Writing safely — the mandatory two-phase protocol
|
|
88
98
|
|
|
89
99
|
Writes are **disabled by default**. The protocol is not optional:
|
|
@@ -136,6 +146,8 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
136
146
|
- **General health** → `sparda doctor` checks Node version, framework detection,
|
|
137
147
|
manifest validity, the semantic/immune cache, host reachability, and quarantine;
|
|
138
148
|
it exits non-zero so it can gate CI.
|
|
149
|
+
- **Clone learning / Transfer sémantique** → Use `sparda seed export` to package your app's descriptions, workflows, and antibodies. Then `sparda seed import --germinate` in another clone to import the structure and germinate simulated twin instances.
|
|
150
|
+
- **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
|
|
139
151
|
|
|
140
152
|
---
|
|
141
153
|
*This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { detectStack } from '../detect.js';
|
|
5
5
|
import { buildNegentropy, renderNegentropy } from './negentropy.js';
|
|
6
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
6
7
|
|
|
7
8
|
// Returns { healthy } so the CLI can exit non-zero on any ✗ — scripts/CI can
|
|
8
9
|
// gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
|
|
@@ -55,7 +56,8 @@ export async function runDoctor(opts) {
|
|
|
55
56
|
);
|
|
56
57
|
|
|
57
58
|
if (stack) {
|
|
58
|
-
const
|
|
59
|
+
const key = resolveSpardaKey(opts.cwd, m);
|
|
60
|
+
const headers = { 'x-sparda-key': key };
|
|
59
61
|
try {
|
|
60
62
|
const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, {
|
|
61
63
|
headers,
|
package/src/commands/evolve.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildGrammar } from './grammar.js';
|
|
|
10
10
|
import { createTwinServer, twinFilePath } from './twin.js';
|
|
11
11
|
import { findByKey } from '../server/crystallize.js';
|
|
12
12
|
import { mergeManifestKeySync } from '../server/persistence.js';
|
|
13
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
13
14
|
|
|
14
15
|
const MAX_CANDIDATES_PER_RUN = 10;
|
|
15
16
|
|
|
@@ -101,7 +102,8 @@ export async function runEvolve(opts) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
// the practice arena: an in-process twin on an ephemeral port
|
|
104
|
-
const
|
|
105
|
+
const localKey = resolveSpardaKey(opts.cwd, manifest);
|
|
106
|
+
const server = createTwinServer(manifest, localKey, exemplars);
|
|
105
107
|
const port = await new Promise((resolve, reject) => {
|
|
106
108
|
server.once('error', reject);
|
|
107
109
|
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
|
@@ -111,7 +113,7 @@ export async function runEvolve(opts) {
|
|
|
111
113
|
const failed = [];
|
|
112
114
|
try {
|
|
113
115
|
for (const c of candidates) {
|
|
114
|
-
const verdict = await trialCandidate(c, { port, localKey
|
|
116
|
+
const verdict = await trialCandidate(c, { port, localKey });
|
|
115
117
|
if (verdict.ok) survivors.push(c);
|
|
116
118
|
else failed.push({ key: c.key, why: verdict.why });
|
|
117
119
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
10
11
|
|
|
11
12
|
// same fingerprint the generators stamp into sparding.toolFingerprints —
|
|
12
13
|
// method|path|params, sha256/8. Divergence = the route's shape changed.
|
|
@@ -191,17 +192,17 @@ export function buildNegentropy({ manifest, currentRoutes, live, detectedPort, c
|
|
|
191
192
|
`${f} is recorded in sparda.json but absent on disk — the organism has a memory of a body it no longer has`,
|
|
192
193
|
'npx sparda-mcp init --yes (regenerates; carry-over keeps your state)',
|
|
193
194
|
);
|
|
194
|
-
} else
|
|
195
|
-
manifest
|
|
196
|
-
!fs.readFileSync(abs, 'utf8').includes(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
} else {
|
|
196
|
+
const key = resolveSpardaKey(cwd, manifest);
|
|
197
|
+
if (key && !fs.readFileSync(abs, 'utf8').includes(key)) {
|
|
198
|
+
add(
|
|
199
|
+
'zombie',
|
|
200
|
+
'high',
|
|
201
|
+
'router/manifest key mismatch',
|
|
202
|
+
`${f} does not carry the manifest's localKey — a stale router from an older init is still wired in`,
|
|
203
|
+
'npx sparda-mcp init --yes',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
package/src/commands/report.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { c, gradient } from '../ui/style.js';
|
|
9
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
9
10
|
|
|
10
11
|
// ── pure: manifest (+ optional live stats) → report data ──────────────────
|
|
11
12
|
|
|
@@ -342,9 +343,10 @@ export async function runReport(opts) {
|
|
|
342
343
|
}
|
|
343
344
|
|
|
344
345
|
let live = null;
|
|
345
|
-
|
|
346
|
+
const key = resolveSpardaKey(opts.cwd, manifest);
|
|
347
|
+
if (manifest.port && key) {
|
|
346
348
|
live = await fetch(`http://127.0.0.1:${manifest.port}/mcp/stats`, {
|
|
347
|
-
headers: { 'x-sparda-key':
|
|
349
|
+
headers: { 'x-sparda-key': key },
|
|
348
350
|
signal: AbortSignal.timeout(1500),
|
|
349
351
|
})
|
|
350
352
|
.then((x) => (x.ok ? x.json() : null))
|
package/src/commands/twin.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import http from 'node:http';
|
|
15
15
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
16
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
16
17
|
|
|
17
18
|
const EXEMPLAR_CAP = 16384; // 16KB per exemplar — a shape, not an archive
|
|
18
19
|
|
|
@@ -26,7 +27,14 @@ export function eligibleForLearning(tool) {
|
|
|
26
27
|
return tool.enabled && tool.method === 'GET' && (tool.pathParams ?? []).length === 0;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export async function learnExemplars(manifest, fetchFn = fetch) {
|
|
30
|
+
export async function learnExemplars(manifest, localKey, fetchFn = fetch) {
|
|
31
|
+
let key = localKey;
|
|
32
|
+
if (typeof localKey === 'function') {
|
|
33
|
+
fetchFn = localKey;
|
|
34
|
+
key = undefined;
|
|
35
|
+
}
|
|
36
|
+
key = key ?? manifest.localKey ?? resolveSpardaKey(process.cwd(), manifest);
|
|
37
|
+
|
|
30
38
|
const exemplars = {};
|
|
31
39
|
const skipped = [];
|
|
32
40
|
for (const [name, t] of Object.entries(manifest.tools ?? {})) {
|
|
@@ -46,7 +54,7 @@ export async function learnExemplars(manifest, fetchFn = fetch) {
|
|
|
46
54
|
method: 'POST',
|
|
47
55
|
headers: {
|
|
48
56
|
'content-type': 'application/json',
|
|
49
|
-
'x-sparda-key':
|
|
57
|
+
'x-sparda-key': key,
|
|
50
58
|
},
|
|
51
59
|
body: JSON.stringify({ tool: name, args: {} }),
|
|
52
60
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -83,13 +91,23 @@ function matchTool(manifest, method, pathname) {
|
|
|
83
91
|
return null;
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
export function createTwinServer(manifest,
|
|
94
|
+
export function createTwinServer(manifest, localKeyOrExemplars, maybeExemplars) {
|
|
95
|
+
let localKey = localKeyOrExemplars;
|
|
96
|
+
let exemplars = maybeExemplars;
|
|
97
|
+
if (
|
|
98
|
+
maybeExemplars === undefined &&
|
|
99
|
+
localKeyOrExemplars &&
|
|
100
|
+
typeof localKeyOrExemplars === 'object'
|
|
101
|
+
) {
|
|
102
|
+
exemplars = localKeyOrExemplars;
|
|
103
|
+
localKey = resolveSpardaKey(process.cwd(), manifest);
|
|
104
|
+
}
|
|
87
105
|
const json = (res, status, obj) => {
|
|
88
106
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
89
107
|
res.end(JSON.stringify(obj));
|
|
90
108
|
};
|
|
91
109
|
const answerFor = (name) =>
|
|
92
|
-
exemplars[name]?.data !== undefined
|
|
110
|
+
(exemplars ?? {})[name]?.data !== undefined
|
|
93
111
|
? exemplars[name].data
|
|
94
112
|
: {
|
|
95
113
|
__sparda_twin__: true,
|
|
@@ -103,7 +121,7 @@ export function createTwinServer(manifest, exemplars) {
|
|
|
103
121
|
|
|
104
122
|
// the /mcp surface, so the unchanged bridge drives the ghost
|
|
105
123
|
if (pathname.startsWith('/mcp')) {
|
|
106
|
-
if (req.headers['x-sparda-key'] !==
|
|
124
|
+
if (req.headers['x-sparda-key'] !== localKey)
|
|
107
125
|
return json(res, 401, { error: 'unauthorized' });
|
|
108
126
|
if (pathname === '/mcp/tools') return json(res, 200, manifest.tools ?? {});
|
|
109
127
|
if (pathname === '/mcp/stats')
|
|
@@ -178,13 +196,14 @@ export async function runTwin(opts, args) {
|
|
|
178
196
|
});
|
|
179
197
|
}
|
|
180
198
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
199
|
+
const localKey = resolveSpardaKey(opts.cwd, manifest);
|
|
181
200
|
const twinPath = twinFilePath(opts.cwd);
|
|
182
201
|
|
|
183
202
|
if (args.includes('--learn') || opts.learn) {
|
|
184
203
|
console.error(
|
|
185
204
|
'[sparda] twin: learning from the LIVE app (one call per eligible GET)...',
|
|
186
205
|
);
|
|
187
|
-
const { exemplars, skipped } = await learnExemplars(manifest);
|
|
206
|
+
const { exemplars, skipped } = await learnExemplars(manifest, localKey);
|
|
188
207
|
const learned = Object.keys(exemplars).length;
|
|
189
208
|
if (!learned && skipped.every((s) => s.reason.startsWith('unreachable'))) {
|
|
190
209
|
throw Object.assign(
|
|
@@ -222,7 +241,7 @@ export async function runTwin(opts, args) {
|
|
|
222
241
|
}
|
|
223
242
|
}
|
|
224
243
|
const port = Number(opts.port) || manifest.port;
|
|
225
|
-
const server = createTwinServer(manifest, exemplars);
|
|
244
|
+
const server = createTwinServer(manifest, localKey, exemplars);
|
|
226
245
|
await new Promise((resolve, reject) => {
|
|
227
246
|
server.once('error', reject);
|
|
228
247
|
server.listen(port, '127.0.0.1', resolve);
|
package/src/generator/express.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parse } from '@babel/parser';
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
|
-
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
8
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
9
9
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -58,7 +58,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
58
58
|
sparding.toolFingerprints = newFingerprints;
|
|
59
59
|
|
|
60
60
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
61
|
-
const localKey = prev
|
|
61
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
62
62
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
63
63
|
const entryDir = path.dirname(entryAbs);
|
|
64
64
|
const ext = entryFile.endsWith('.ts')
|
|
@@ -111,7 +111,10 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
111
111
|
)
|
|
112
112
|
.replace('__JSON_MW__', "express.json({ limit: '64kb' })")
|
|
113
113
|
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
114
|
-
.replace(
|
|
114
|
+
.replace(
|
|
115
|
+
'__FS_IMPORT__',
|
|
116
|
+
isESM ? "import spardaFs from 'node:fs';" : "const spardaFs = require('node:fs');",
|
|
117
|
+
)
|
|
115
118
|
.replace('__PORT__', String(port))
|
|
116
119
|
.replace(/__REQ_TYPE__/g, reqType)
|
|
117
120
|
.replace(/__RES_TYPE__/g, resType)
|
|
@@ -150,7 +153,15 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
150
153
|
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
151
154
|
sparding,
|
|
152
155
|
};
|
|
153
|
-
|
|
156
|
+
// ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
|
|
157
|
+
// (written by ensureSpardaKey above). The in-memory return keeps it for
|
|
158
|
+
// this process only.
|
|
159
|
+
const manifestOnDisk = { ...manifest };
|
|
160
|
+
delete manifestOnDisk.localKey;
|
|
161
|
+
atomicWrite(
|
|
162
|
+
path.join(cwd, 'sparda.json'),
|
|
163
|
+
JSON.stringify(manifestOnDisk, null, 2) + '\n',
|
|
164
|
+
);
|
|
154
165
|
|
|
155
166
|
return {
|
|
156
167
|
tools,
|
package/src/generator/fastapi.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
|
-
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
8
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
9
9
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -65,7 +65,7 @@ export function generateFastAPI({
|
|
|
65
65
|
sparding.toolFingerprints = newFingerprints;
|
|
66
66
|
|
|
67
67
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
68
|
-
const localKey = prev
|
|
68
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
69
69
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
70
70
|
const entryDir = path.dirname(entryAbs);
|
|
71
71
|
const routerFileName = 'sparda_router.py';
|
|
@@ -84,7 +84,6 @@ export function generateFastAPI({
|
|
|
84
84
|
'__SPARDING_POLICIES__',
|
|
85
85
|
JSON.stringify(JSON.stringify(sparding.policies ?? {})),
|
|
86
86
|
)
|
|
87
|
-
.replace('__LOCAL_KEY__', localKey)
|
|
88
87
|
.replace('__PORT__', String(port));
|
|
89
88
|
|
|
90
89
|
atomicWrite(routerAbs, tpl);
|
|
@@ -124,7 +123,15 @@ export function generateFastAPI({
|
|
|
124
123
|
sparding,
|
|
125
124
|
};
|
|
126
125
|
|
|
127
|
-
|
|
126
|
+
// ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
|
|
127
|
+
// (written by ensureSpardaKey above). The in-memory return keeps it for
|
|
128
|
+
// this process only.
|
|
129
|
+
const manifestOnDisk = { ...manifest };
|
|
130
|
+
delete manifestOnDisk.localKey;
|
|
131
|
+
atomicWrite(
|
|
132
|
+
path.join(cwd, 'sparda.json'),
|
|
133
|
+
JSON.stringify(manifestOnDisk, null, 2) + '\n',
|
|
134
|
+
);
|
|
128
135
|
|
|
129
136
|
return {
|
|
130
137
|
tools,
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
// generator/manifest.js — carry user state across re-runs of `sparda init`
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function resolveSpardaKey(cwd, prevManifest = null) {
|
|
7
|
+
if (process.env.SPARDA_LOCAL_KEY) {
|
|
8
|
+
return process.env.SPARDA_LOCAL_KEY;
|
|
9
|
+
}
|
|
10
|
+
const spardaDir = path.join(cwd, '.sparda');
|
|
11
|
+
const keyFile = path.join(spardaDir, 'key');
|
|
12
|
+
if (fs.existsSync(keyFile)) {
|
|
13
|
+
try {
|
|
14
|
+
const key = fs.readFileSync(keyFile, 'utf8').trim();
|
|
15
|
+
if (key) return key;
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
18
|
+
if (prevManifest && prevManifest.localKey) {
|
|
19
|
+
try {
|
|
20
|
+
fs.mkdirSync(spardaDir, { recursive: true });
|
|
21
|
+
fs.writeFileSync(keyFile, prevManifest.localKey, 'utf8');
|
|
22
|
+
} catch {}
|
|
23
|
+
return prevManifest.localKey;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ensureSpardaKey(cwd, prevManifest = null) {
|
|
29
|
+
let key = resolveSpardaKey(cwd, prevManifest);
|
|
30
|
+
if (!key) {
|
|
31
|
+
key = crypto.randomUUID();
|
|
32
|
+
const spardaDir = path.join(cwd, '.sparda');
|
|
33
|
+
fs.mkdirSync(spardaDir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(spardaDir, 'key'), key, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
4
38
|
|
|
5
39
|
// Re-running init must not wipe what the user (or the semantic/immune/Labs
|
|
6
40
|
// passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
|
package/src/generator/nextjs.js
CHANGED
|
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { toolNameFor } from '../parser/express.js';
|
|
11
11
|
import { ensureGitignore } from './express.js';
|
|
12
|
-
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
12
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
13
13
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
14
14
|
|
|
15
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -60,7 +60,7 @@ export function generateNext({ cwd, appDir, port, routes }) {
|
|
|
60
60
|
sparding.toolFingerprints = newFingerprints;
|
|
61
61
|
|
|
62
62
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
63
|
-
const localKey = prev
|
|
63
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
64
64
|
|
|
65
65
|
// .js on purpose: Next enables allowJs in the tsconfig it manages, so the
|
|
66
66
|
// generated handler compiles untouched inside TS projects too.
|
|
@@ -75,7 +75,6 @@ export function generateNext({ cwd, appDir, port, routes }) {
|
|
|
75
75
|
tpl = tpl
|
|
76
76
|
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
77
77
|
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
78
|
-
.replace('__LOCAL_KEY__', localKey)
|
|
79
78
|
.replace('__PORT__', String(port));
|
|
80
79
|
atomicWrite(routerAbs, tpl);
|
|
81
80
|
|
|
@@ -102,7 +101,15 @@ export function generateNext({ cwd, appDir, port, routes }) {
|
|
|
102
101
|
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
103
102
|
sparding,
|
|
104
103
|
};
|
|
105
|
-
|
|
104
|
+
// ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
|
|
105
|
+
// (written by ensureSpardaKey above). The in-memory return keeps it for
|
|
106
|
+
// this process only.
|
|
107
|
+
const manifestOnDisk = { ...manifest };
|
|
108
|
+
delete manifestOnDisk.localKey;
|
|
109
|
+
atomicWrite(
|
|
110
|
+
path.join(cwd, 'sparda.json'),
|
|
111
|
+
JSON.stringify(manifestOnDisk, null, 2) + '\n',
|
|
112
|
+
);
|
|
106
113
|
|
|
107
114
|
return {
|
|
108
115
|
tools,
|
package/src/server/stdio.js
CHANGED
|
@@ -25,6 +25,7 @@ import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
|
|
|
25
25
|
import { createSpardaEngine } from './engine.js';
|
|
26
26
|
import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
|
|
27
27
|
import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
|
|
28
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
28
29
|
|
|
29
30
|
const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
|
|
30
31
|
// the sampling budgets below are also what the recycling gauge counts as "avoided"
|
|
@@ -61,11 +62,11 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
61
62
|
}
|
|
62
63
|
const port = Number(portOverride ?? manifest.port);
|
|
63
64
|
const framework = manifest.framework;
|
|
64
|
-
const key = manifest
|
|
65
|
+
const key = resolveSpardaKey(cwd, manifest);
|
|
65
66
|
if (!key) {
|
|
66
|
-
throw Object.assign(new Error('localKey missing
|
|
67
|
+
throw Object.assign(new Error('localKey missing or not configured.'), {
|
|
67
68
|
code: 'USER',
|
|
68
|
-
hint: 'Re-run `npx sparda-mcp init` to
|
|
69
|
+
hint: 'Re-run `npx sparda-mcp init` to generate it, or set SPARDA_LOCAL_KEY.',
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -7,7 +7,21 @@ __IMPORT_LINE__
|
|
|
7
7
|
const SPARDA_TOOLS = __TOOLS_JSON__;
|
|
8
8
|
const SPARDA_POLICIES = __SPARDING_POLICIES__;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// ADR-022: the key is NEVER baked into this committed file. It resolves at
|
|
11
|
+
// runtime — env first, then the gitignored .sparda/key — and fails CLOSED:
|
|
12
|
+
// no key, no /mcp surface. A deploy that ships without .sparda/ is inert.
|
|
13
|
+
__FS_IMPORT__
|
|
14
|
+
let SPARDA_LOCAL_KEY = process.env.SPARDA_LOCAL_KEY || null;
|
|
15
|
+
if (!SPARDA_LOCAL_KEY) {
|
|
16
|
+
try {
|
|
17
|
+
for (const p of ['.sparda/key', '../.sparda/key', '../../.sparda/key']) {
|
|
18
|
+
if (spardaFs.existsSync(p)) {
|
|
19
|
+
SPARDA_LOCAL_KEY = spardaFs.readFileSync(p, 'utf8').trim() || null;
|
|
20
|
+
if (SPARDA_LOCAL_KEY) break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch { /* stays null -> fail closed */ }
|
|
24
|
+
}
|
|
11
25
|
const SPARDA_PORT = __PORT__;
|
|
12
26
|
|
|
13
27
|
const SPARDA_STATS__STATS_TYPE__ = {};
|
|
@@ -299,6 +313,9 @@ async function spardaExecute(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE_
|
|
|
299
313
|
__ROUTER_DECL__
|
|
300
314
|
|
|
301
315
|
spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
|
|
316
|
+
if (!SPARDA_LOCAL_KEY) {
|
|
317
|
+
return res.status(503).json({ error: 'key not configured' });
|
|
318
|
+
}
|
|
302
319
|
if (req.headers['x-sparda-key'] !== SPARDA_LOCAL_KEY) {
|
|
303
320
|
return res.status(401).json({ error: 'unauthorized' });
|
|
304
321
|
}
|
|
@@ -19,7 +19,20 @@ import threading
|
|
|
19
19
|
# json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
|
|
20
20
|
SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
|
|
21
21
|
SPARDA_POLICIES = json.loads(__SPARDING_POLICIES__)
|
|
22
|
-
|
|
22
|
+
# ADR-022: the key is NEVER baked into this committed file. It resolves at
|
|
23
|
+
# runtime — env first, then the gitignored .sparda/key — and fails CLOSED:
|
|
24
|
+
# no key, no /mcp surface. A deploy that ships without .sparda/ is inert.
|
|
25
|
+
SPARDA_LOCAL_KEY = os.environ.get("SPARDA_LOCAL_KEY") or None
|
|
26
|
+
if not SPARDA_LOCAL_KEY:
|
|
27
|
+
try:
|
|
28
|
+
for p in [".sparda/key", "../.sparda/key", "../../.sparda/key"]:
|
|
29
|
+
if os.path.exists(p):
|
|
30
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
31
|
+
SPARDA_LOCAL_KEY = f.read().strip() or None
|
|
32
|
+
if SPARDA_LOCAL_KEY:
|
|
33
|
+
break
|
|
34
|
+
except Exception:
|
|
35
|
+
pass # stays None -> fail closed
|
|
23
36
|
SPARDA_PORT = __PORT__
|
|
24
37
|
|
|
25
38
|
SPARDA_STATS = {}
|
|
@@ -363,18 +376,24 @@ async def sparda_read_json(request):
|
|
|
363
376
|
|
|
364
377
|
@sparda_router.get("/tools")
|
|
365
378
|
async def get_tools(request: Request):
|
|
379
|
+
if not SPARDA_LOCAL_KEY:
|
|
380
|
+
return JSONResponse(status_code=503, content={"error": "key not configured"})
|
|
366
381
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
367
382
|
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
368
383
|
return SPARDA_TOOLS
|
|
369
384
|
|
|
370
385
|
@sparda_router.get("/stats")
|
|
371
386
|
async def get_stats(request: Request):
|
|
387
|
+
if not SPARDA_LOCAL_KEY:
|
|
388
|
+
return JSONResponse(status_code=503, content={"error": "key not configured"})
|
|
372
389
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
373
390
|
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
374
391
|
return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE, "recycle": {**SPARDA_RECYCLE, "ratePct": sparda_recycle_rate()}, "purity": sparda_purity_snapshot()}
|
|
375
392
|
|
|
376
393
|
@sparda_router.get("/events")
|
|
377
394
|
async def get_events(request: Request):
|
|
395
|
+
if not SPARDA_LOCAL_KEY:
|
|
396
|
+
return JSONResponse(status_code=503, content={"error": "key not configured"})
|
|
378
397
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
379
398
|
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
380
399
|
try:
|
|
@@ -388,6 +407,8 @@ async def gossip(request: Request):
|
|
|
388
407
|
# peer-to-peer convergence (Brief #2), behind the same x-sparda-key as every route.
|
|
389
408
|
# Absorbs a peer's grow-only counts via CRDT max-merge; 204 on success. An unknown tool
|
|
390
409
|
# or malformed count is silently dropped (bounded, no injection).
|
|
410
|
+
if not SPARDA_LOCAL_KEY:
|
|
411
|
+
return JSONResponse(status_code=503, content={"error": "key not configured"})
|
|
391
412
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
392
413
|
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
393
414
|
body, err = await sparda_read_json(request)
|
|
@@ -398,6 +419,8 @@ async def gossip(request: Request):
|
|
|
398
419
|
|
|
399
420
|
@sparda_router.post("/invoke")
|
|
400
421
|
async def invoke_tool(request: Request):
|
|
422
|
+
if not SPARDA_LOCAL_KEY:
|
|
423
|
+
return JSONResponse(status_code=503, content={"error": "key not configured"})
|
|
401
424
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
402
425
|
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
403
426
|
|
|
@@ -543,6 +566,8 @@ async def invoke_tool(request: Request):
|
|
|
543
566
|
|
|
544
567
|
@sparda_router.post("/invoke/confirm")
|
|
545
568
|
async def confirm_tool_invocation(request: Request):
|
|
569
|
+
if not SPARDA_LOCAL_KEY:
|
|
570
|
+
return JSONResponse(status_code=503, content={"error": "key not configured"})
|
|
546
571
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
547
572
|
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
548
573
|
|
|
@@ -8,7 +8,21 @@
|
|
|
8
8
|
const SPARDA_TOOLS = __TOOLS_JSON__;
|
|
9
9
|
const SPARDA_POLICIES = __SPARDING_POLICIES__;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// ADR-022: the key is NEVER baked into this committed file. It resolves at
|
|
12
|
+
// runtime — env first, then the gitignored .sparda/key — and fails CLOSED:
|
|
13
|
+
// no key, no /mcp surface. A Vercel deploy that ships without .sparda/ is inert.
|
|
14
|
+
import spardaFs from 'node:fs';
|
|
15
|
+
let SPARDA_LOCAL_KEY = process.env.SPARDA_LOCAL_KEY || null;
|
|
16
|
+
if (!SPARDA_LOCAL_KEY) {
|
|
17
|
+
try {
|
|
18
|
+
for (const p of ['.sparda/key', '../.sparda/key', '../../.sparda/key']) {
|
|
19
|
+
if (spardaFs.existsSync(p)) {
|
|
20
|
+
SPARDA_LOCAL_KEY = spardaFs.readFileSync(p, 'utf8').trim() || null;
|
|
21
|
+
if (SPARDA_LOCAL_KEY) break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch { /* stays null -> fail closed */ }
|
|
25
|
+
}
|
|
12
26
|
const SPARDA_PORT = __PORT__;
|
|
13
27
|
|
|
14
28
|
// never statically optimized, always on the Node.js runtime (process, timers)
|
|
@@ -336,6 +350,9 @@ async function spardaConfirm(request) {
|
|
|
336
350
|
|
|
337
351
|
async function spardaHandle(request, ctx) {
|
|
338
352
|
try {
|
|
353
|
+
if (!SPARDA_LOCAL_KEY) {
|
|
354
|
+
return spardaJson(503, { error: 'key not configured' });
|
|
355
|
+
}
|
|
339
356
|
if (request.headers.get('x-sparda-key') !== SPARDA_LOCAL_KEY) {
|
|
340
357
|
return spardaJson(401, { error: 'unauthorized' });
|
|
341
358
|
}
|