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 +2 -0
- package/package.json +1 -1
- package/src/server/crystallize.js +106 -0
- package/src/server/stdio.js +49 -0
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
|
@@ -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 ?? '')
|
package/src/server/stdio.js
CHANGED
|
@@ -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) => {
|