sparda-mcp 0.7.0 → 0.7.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
@@ -105,6 +105,8 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
105
105
  2. Tool calls run **inside your live app process** — warm DB pools, real auth chain, real data. SPARDA adds no infrastructure: compute comes from your host process, intelligence from your AI client's own model (MCP sampling), storage from `sparda.json` + git.
106
106
  3. Write tools (POST/PUT/DELETE) are **disabled by default**. You opt in per tool in `sparda.json` — your choices survive re-runs.
107
107
  4. Suspicious docstrings are sanitized before they ever reach the AI (prompt-injection defense).
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
+ 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.
108
110
 
109
111
  ## What SPARDA gives your AI
110
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.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",
@@ -32,6 +32,112 @@ export function fallbackComposite(circuit) {
32
32
  };
33
33
  }
34
34
 
35
+ // ── R2.4: nothing disappears, x becomes y ──────────────────────────────────
36
+ // Tool names derive from method+path, so a renamed route means a vanished step
37
+ // name. Instead of letting the composite die silently at wake-up, we look for
38
+ // the step's UNIQUE deterministic successor: an enabled GET whose name keeps
39
+ // the old name's segments in order (api_users ⊂ api_v2_users) and ends on the
40
+ // same resource segment. One candidate → re-map; zero or many → dormant with a
41
+ // recorded reason. Never a guess: ambiguity is a reason, not a coin flip.
42
+
43
+ function nameSegments(toolName) {
44
+ const parts = String(toolName).split('_');
45
+ return ['get', 'post', 'put', 'patch', 'delete'].includes(parts[0])
46
+ ? parts.slice(1)
47
+ : parts;
48
+ }
49
+
50
+ function isSubsequence(small, big) {
51
+ let i = 0;
52
+ for (const seg of big) if (seg === small[i]) i++;
53
+ return i === small.length;
54
+ }
55
+
56
+ export function successorFor(oldName, toolSpecs, exclude = new Set()) {
57
+ const oldSegs = nameSegments(oldName);
58
+ if (!oldSegs.length) return null;
59
+ const last = oldSegs[oldSegs.length - 1];
60
+ const candidates = Object.entries(toolSpecs)
61
+ .filter(([name, t]) => {
62
+ if (exclude.has(name) || !t.enabled || t.method !== 'GET') return false;
63
+ const segs = nameSegments(name);
64
+ return segs[segs.length - 1] === last && isSubsequence(oldSegs, segs);
65
+ })
66
+ .map(([name]) => name);
67
+ return candidates.length === 1 ? candidates[0] : null;
68
+ }
69
+
70
+ // (circuits map, today's tools) → what survives, what becomes, what sleeps.
71
+ // Pure: never mutates the input; the caller decides what to persist.
72
+ export function remapComposites(circuits, toolSpecs) {
73
+ const remapped = [];
74
+ const dormant = [];
75
+ for (const [oldKey, circuit] of Object.entries(circuits ?? {})) {
76
+ if (!circuit?.composite?.name) continue;
77
+ if (eligibleForCrystallization(circuit, toolSpecs)) continue; // alive as-is
78
+
79
+ const disabled = (circuit.steps ?? []).filter(
80
+ (s) => toolSpecs[s] && !toolSpecs[s].enabled,
81
+ );
82
+ if (disabled.length) {
83
+ // the user disabled a step on purpose — respect it, do not re-route around it
84
+ dormant.push({
85
+ composite: circuit.composite.name,
86
+ key: oldKey,
87
+ reason: `step disabled by the user: ${disabled.join(', ')} — composite sleeps until re-enabled`,
88
+ });
89
+ continue;
90
+ }
91
+
92
+ const renames = {};
93
+ const newSteps = [];
94
+ let resolvable = true;
95
+ for (const step of circuit.steps ?? []) {
96
+ if (toolSpecs[step]?.enabled && toolSpecs[step].method === 'GET') {
97
+ newSteps.push(step);
98
+ continue;
99
+ }
100
+ const successor = successorFor(step, toolSpecs, new Set(newSteps));
101
+ if (!successor) {
102
+ resolvable = false;
103
+ dormant.push({
104
+ composite: circuit.composite.name,
105
+ key: oldKey,
106
+ reason: `step vanished with no unique successor: ${step} — structure kept, waiting for a sync that brings it back`,
107
+ });
108
+ break;
109
+ }
110
+ renames[step] = successor;
111
+ newSteps.push(successor);
112
+ }
113
+ if (!resolvable) continue;
114
+
115
+ const next = structuredClone(circuit);
116
+ next.steps = newSteps;
117
+ next.links = (next.links ?? []).map((l) => ({
118
+ ...l,
119
+ from: renames[l.from] ?? l.from,
120
+ to: renames[l.to] ?? l.to,
121
+ }));
122
+ if (!eligibleForCrystallization(next, toolSpecs)) {
123
+ dormant.push({
124
+ composite: circuit.composite.name,
125
+ key: oldKey,
126
+ reason:
127
+ 'successor found but the re-mapped circuit is not crystallizable — kept dormant',
128
+ });
129
+ continue;
130
+ }
131
+ remapped.push({
132
+ oldKey,
133
+ newKey: next.steps.join('>'),
134
+ circuit: next,
135
+ renames,
136
+ });
137
+ }
138
+ return { remapped, dormant };
139
+ }
140
+
35
141
  // a sampled name is untrusted input: normalize hard, reject anything shapeless
36
142
  export function normalizeCompositeName(raw) {
37
143
  const n = String(raw ?? '')
@@ -15,6 +15,7 @@ import { createIdleHarvester } from './idle.js';
15
15
  import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
16
16
  import {
17
17
  eligibleForCrystallization,
18
+ remapComposites,
18
19
  fallbackComposite,
19
20
  normalizeCompositeName,
20
21
  compositeSchema,
@@ -228,11 +229,59 @@ export async function startStdioBridge({ cwd, portOverride }) {
228
229
  // composites born in past sessions wake up with the bridge — re-validated
229
230
  // against today's tools (a route may have changed or been disabled since)
230
231
  if (recorder) {
232
+ // R2.4 — nothing disappears, x becomes y: a step whose route was renamed is
233
+ // re-mapped to its UNIQUE deterministic successor instead of killing the
234
+ // composite silently; the unmappable go dormant WITH a recorded lesson.
235
+ const { remapped, dormant } = remapComposites(
236
+ manifest.labs?.circuits ?? {},
237
+ toolSpecs,
238
+ );
231
239
  for (const [sig, c] of Object.entries(manifest.labs?.circuits ?? {})) {
232
240
  if (c.composite?.name && eligibleForCrystallization(c, toolSpecs)) {
233
241
  composites.set(uniqueCompositeName(c.composite.name), { sig, circuit: c });
234
242
  }
235
243
  }
244
+ for (const r of remapped) {
245
+ delete manifest.labs.circuits[r.oldKey];
246
+ manifest.labs.circuits[r.newKey] = r.circuit;
247
+ composites.set(uniqueCompositeName(r.circuit.composite.name), {
248
+ sig: r.newKey,
249
+ circuit: r.circuit,
250
+ });
251
+ manifest.sparding ??= {};
252
+ manifest.sparding.events ??= [];
253
+ manifest.sparding.events.push({
254
+ ts: new Date().toISOString(),
255
+ tool: r.circuit.composite.name,
256
+ decision: 'audit',
257
+ risk: 'low',
258
+ reasons: [
259
+ `composite re-mapped (R2.4): ${Object.entries(r.renames)
260
+ .map(([a, b]) => `${a} → ${b}`)
261
+ .join(', ')}`,
262
+ ],
263
+ });
264
+ if (manifest.sparding.events.length > 100) manifest.sparding.events.shift();
265
+ console.error(
266
+ `[sparda] R2.4: composite '${r.circuit.composite.name}' re-mapped (${Object.entries(
267
+ r.renames,
268
+ )
269
+ .map(([a, b]) => `${a} → ${b}`)
270
+ .join(', ')})`,
271
+ );
272
+ }
273
+ for (const d of dormant) {
274
+ manifest.sparding ??= {};
275
+ manifest.sparding.failures ??= {};
276
+ const fsig = `composite|${d.composite}|dormant`;
277
+ const prev = manifest.sparding.failures[fsig] ?? { count: 0, lesson: '' };
278
+ manifest.sparding.failures[fsig] = { count: prev.count + 1, lesson: d.reason };
279
+ console.error(`[sparda] R2.4: composite '${d.composite}' dormant — ${d.reason}`);
280
+ }
281
+ if (remapped.length || dormant.length) {
282
+ mergeManifestKeySync(manifestPath, 'labs', manifest.labs);
283
+ mergeManifestKeySync(manifestPath, 'sparding', manifest.sparding);
284
+ }
236
285
  }
237
286
 
238
287
  const descFor = (name, t) => {