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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
6
6
  "type": "module",
@@ -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 headers = { 'x-sparda-key': m.localKey };
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,
@@ -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 server = createTwinServer(manifest, exemplars);
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: manifest.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 if (
195
- manifest.localKey &&
196
- !fs.readFileSync(abs, 'utf8').includes(manifest.localKey)
197
- ) {
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
- );
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
 
@@ -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
- if (manifest.port && manifest.localKey) {
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': manifest.localKey },
349
+ headers: { 'x-sparda-key': key },
348
350
  signal: AbortSignal.timeout(1500),
349
351
  })
350
352
  .then((x) => (x.ok ? x.json() : null))
@@ -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': manifest.localKey,
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, exemplars) {
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'] !== manifest.localKey)
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);
@@ -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?.localKey ?? crypto.randomUUID();
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('__LOCAL_KEY__', localKey)
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
- atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
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,
@@ -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?.localKey ?? crypto.randomUUID();
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
- atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
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
@@ -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?.localKey ?? crypto.randomUUID();
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
- atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
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,
@@ -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.localKey;
65
+ const key = resolveSpardaKey(cwd, manifest);
65
66
  if (!key) {
66
- throw Object.assign(new Error('localKey missing from sparda.json.'), {
67
+ throw Object.assign(new Error('localKey missing or not configured.'), {
67
68
  code: 'USER',
68
- hint: 'Re-run `npx sparda-mcp init` to regenerate it.',
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
- const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
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
- SPARDA_LOCAL_KEY = "__LOCAL_KEY__"
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
- const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
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
  }