sparda-mcp 0.5.2 → 0.5.4
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 +158 -40
- package/SKILL.md +144 -0
- package/demo-app/package.json +7 -0
- package/demo-app/src/app.js +28 -0
- package/demo-app/src/routes/users.js +8 -0
- package/package.json +25 -3
- package/src/commands/demo.js +154 -0
- package/src/commands/doctor.js +51 -17
- package/src/commands/hook.js +21 -5
- package/src/commands/init.js +105 -33
- package/src/commands/remove.js +29 -10
- package/src/commands/report.js +372 -0
- package/src/commands/sync.js +37 -8
- package/src/detect.js +68 -18
- package/src/generator/express.js +86 -28
- package/src/generator/fastapi.js +66 -20
- package/src/generator/manifest.js +20 -13
- package/src/index.js +22 -2
- package/src/parser/express.js +144 -44
- package/src/parser/fastapi.js +13 -4
- package/src/probe/express-shim-esm.mjs +2 -2
- package/src/probe/express-shim.cjs +65 -22
- package/src/probe/integrate.js +34 -12
- package/src/probe/probe.js +59 -33
- package/src/probe/reconcile.js +24 -22
- package/src/security/sanitize.js +5 -1
- package/src/server/condenser.js +34 -12
- package/src/server/confirmation.js +75 -19
- package/src/server/context-carrier.js +36 -31
- package/src/server/crystallize.js +32 -9
- package/src/server/engine.js +118 -38
- package/src/server/idle.js +20 -4
- package/src/server/persistence.js +16 -5
- package/src/server/stdio.js +490 -162
- package/src/ui/style.js +30 -18
- package/templates/fastapi-router.txt +41 -13
package/src/server/stdio.js
CHANGED
|
@@ -5,13 +5,21 @@ import path from 'node:path';
|
|
|
5
5
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import {
|
|
8
|
-
CallToolRequestSchema,
|
|
9
|
-
|
|
8
|
+
CallToolRequestSchema,
|
|
9
|
+
ListToolsRequestSchema,
|
|
10
|
+
ListPromptsRequestSchema,
|
|
11
|
+
GetPromptRequestSchema,
|
|
10
12
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
11
13
|
import { sanitizeDescription } from '../security/sanitize.js';
|
|
12
14
|
import { createIdleHarvester } from './idle.js';
|
|
13
15
|
import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
eligibleForCrystallization,
|
|
18
|
+
fallbackComposite,
|
|
19
|
+
normalizeCompositeName,
|
|
20
|
+
compositeSchema,
|
|
21
|
+
runComposite,
|
|
22
|
+
} from './crystallize.js';
|
|
15
23
|
import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
|
|
16
24
|
import { createSpardaEngine } from './engine.js';
|
|
17
25
|
import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
|
|
@@ -30,14 +38,34 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
30
38
|
|
|
31
39
|
const manifestPath = path.join(cwd, 'sparda.json');
|
|
32
40
|
if (!fs.existsSync(manifestPath)) {
|
|
33
|
-
throw Object.assign(new Error('sparda.json not found.'), {
|
|
41
|
+
throw Object.assign(new Error('sparda.json not found.'), {
|
|
42
|
+
code: 'USER',
|
|
43
|
+
hint: 'Run `npx sparda-mcp init` first.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
let manifest;
|
|
47
|
+
try {
|
|
48
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// A truncated/garbled sparda.json (interrupted write, bad merge) must fail
|
|
51
|
+
// with the same code:'USER'+hint discipline used everywhere else — never a
|
|
52
|
+
// raw SyntaxError that crashes the bridge with an unreadable stack.
|
|
53
|
+
throw Object.assign(
|
|
54
|
+
new Error(`sparda.json is unreadable or corrupted: ${e.message}`),
|
|
55
|
+
{
|
|
56
|
+
code: 'USER',
|
|
57
|
+
hint: 'Restore it from git, or re-run `npx sparda-mcp init` to regenerate.',
|
|
58
|
+
},
|
|
59
|
+
);
|
|
34
60
|
}
|
|
35
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
36
61
|
const port = Number(portOverride ?? manifest.port);
|
|
37
62
|
const framework = manifest.framework;
|
|
38
63
|
const key = manifest.localKey;
|
|
39
64
|
if (!key) {
|
|
40
|
-
throw Object.assign(new Error('localKey missing from sparda.json.'), {
|
|
65
|
+
throw Object.assign(new Error('localKey missing from sparda.json.'), {
|
|
66
|
+
code: 'USER',
|
|
67
|
+
hint: 'Re-run `npx sparda-mcp init` to regenerate it.',
|
|
68
|
+
});
|
|
41
69
|
}
|
|
42
70
|
|
|
43
71
|
// Brief #4 — tenant context carrier. Resolved ONCE, here, from operator-pinned
|
|
@@ -46,23 +74,37 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
46
74
|
// per call) is the only scope it cannot choose. Throws USER on CRLF/over-bound so
|
|
47
75
|
// a malformed scope stops the bridge instead of degrading to "no scope". Absent
|
|
48
76
|
// config → frozen empty map → injectContext is a no-op (byte-identical forwarding).
|
|
49
|
-
const ctx = resolveContext({
|
|
77
|
+
const ctx = resolveContext({
|
|
78
|
+
argv: process.argv,
|
|
79
|
+
env: process.env,
|
|
80
|
+
config: manifest.contextPropagation ?? null,
|
|
81
|
+
});
|
|
50
82
|
const ctxFp = fingerprintContext(ctx); // value-free: names + 8-hex SHA-256 only (R2/R7)
|
|
51
83
|
if (Object.keys(ctxFp).length > 0) {
|
|
52
|
-
console.error(
|
|
84
|
+
console.error(
|
|
85
|
+
`[sparda] context carrier active (forwarded verbatim on every host call): ${JSON.stringify(ctxFp)}`,
|
|
86
|
+
);
|
|
53
87
|
}
|
|
54
88
|
|
|
55
89
|
const base = await waitForHost(port, key, framework, ctx);
|
|
56
90
|
|
|
57
91
|
// full tool specs live in the generated router; fetch them (single source of truth)
|
|
58
|
-
const toolSpecs = await (
|
|
92
|
+
const toolSpecs = await (
|
|
93
|
+
await fetch(`${base}/mcp/tools`, { headers: hostHeaders(key, ctx) })
|
|
94
|
+
).json();
|
|
59
95
|
|
|
60
96
|
const enabled = () => Object.entries(toolSpecs).filter(([, t]) => t.enabled);
|
|
61
97
|
const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
|
|
62
98
|
|
|
63
99
|
const server = new Server(
|
|
64
100
|
{ name: `sparda-${path.basename(cwd)}`, version: '0.5.2' },
|
|
65
|
-
{
|
|
101
|
+
{
|
|
102
|
+
capabilities: {
|
|
103
|
+
tools: { listChanged: true },
|
|
104
|
+
prompts: { listChanged: true },
|
|
105
|
+
logging: {},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
66
108
|
);
|
|
67
109
|
|
|
68
110
|
// R4.1, intelligence side of the gauge: sampling calls NOT spent because cached
|
|
@@ -87,12 +129,20 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
87
129
|
const composites = new Map(); // composite tool name -> { sig, circuit }
|
|
88
130
|
const recorder = sequenceRecordingEnabled(manifest)
|
|
89
131
|
? createSequenceRecorder({
|
|
90
|
-
manifest,
|
|
91
|
-
|
|
132
|
+
manifest,
|
|
133
|
+
manifestPath,
|
|
134
|
+
harvester,
|
|
135
|
+
onCircuit: (sig, c) => {
|
|
136
|
+
crystallizeCircuit(sig, c).catch((e) =>
|
|
137
|
+
console.error(`[sparda] crystallization skipped: ${e.message}`),
|
|
138
|
+
);
|
|
139
|
+
},
|
|
92
140
|
})
|
|
93
141
|
: null;
|
|
94
142
|
|
|
95
|
-
const compositeNameTaken = (n) =>
|
|
143
|
+
const compositeNameTaken = (n) =>
|
|
144
|
+
Boolean(toolSpecs[n]) ||
|
|
145
|
+
composites.has(n) ||
|
|
96
146
|
['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context'].includes(n);
|
|
97
147
|
const uniqueCompositeName = (base) => {
|
|
98
148
|
if (!compositeNameTaken(base)) return base;
|
|
@@ -102,33 +152,56 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
102
152
|
async function crystallizeCircuit(sig, circuit) {
|
|
103
153
|
if (circuit.composite || !eligibleForCrystallization(circuit, toolSpecs)) {
|
|
104
154
|
// observed but not crystallizable (write step, missing fromKey…): say so, once
|
|
105
|
-
await server
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
155
|
+
await server
|
|
156
|
+
.sendLoggingMessage({
|
|
157
|
+
level: 'info',
|
|
158
|
+
logger: 'sparda',
|
|
159
|
+
data: {
|
|
160
|
+
source: 'condenser',
|
|
161
|
+
circuit: sig,
|
|
162
|
+
seen: circuit.seen,
|
|
163
|
+
note: `circuit observed ${circuit.seen}× — not crystallized (composites are GET-only and need a traceable data flow)`,
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {});
|
|
109
167
|
return;
|
|
110
168
|
}
|
|
111
169
|
// the client's LLM names the newborn (one call, ever); no sampling → deterministic name
|
|
112
170
|
let named = null;
|
|
113
171
|
if (server.getClientCapabilities()?.sampling) {
|
|
114
172
|
try {
|
|
115
|
-
const flow = circuit.links
|
|
173
|
+
const flow = circuit.links
|
|
174
|
+
.map(
|
|
175
|
+
(l) =>
|
|
176
|
+
`'${l.arg}' of ${l.to} comes from '${l.fromKey}' in the response of ${l.from}`,
|
|
177
|
+
)
|
|
178
|
+
.join('; ');
|
|
116
179
|
const res = await server.createMessage({
|
|
117
|
-
messages: [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
180
|
+
messages: [
|
|
181
|
+
{
|
|
182
|
+
role: 'user',
|
|
183
|
+
content: {
|
|
184
|
+
type: 'text',
|
|
185
|
+
text: `A repeated tool-call sequence was observed ${circuit.seen}× in a live ${manifest.framework} app:\nsteps: ${circuit.steps.join(' -> ')}\ndata flow: ${flow}\ntools:\n${circuit.steps.map((s) => `- ${s}: ${toolSpecs[s].method} ${toolSpecs[s].path}`).join('\n')}\n\nReply with ONLY a JSON object {"name": "<snake_case verb_noun, max 40 chars>", "description": "<one sentence: what this combined operation achieves in business terms>"}.`,
|
|
186
|
+
},
|
|
122
187
|
},
|
|
123
|
-
|
|
188
|
+
],
|
|
124
189
|
maxTokens: CRYSTAL_TOKENS,
|
|
125
190
|
});
|
|
126
191
|
const raw = res?.content?.type === 'text' ? res.content.text : '';
|
|
127
|
-
const parsed = JSON.parse(
|
|
192
|
+
const parsed = JSON.parse(
|
|
193
|
+
raw.replace(/^```(json)?\s*/i, '').replace(/```\s*$/, ''),
|
|
194
|
+
);
|
|
128
195
|
const name = normalizeCompositeName(parsed.name);
|
|
129
|
-
const { text: desc, flagged } = sanitizeDescription(
|
|
130
|
-
|
|
131
|
-
|
|
196
|
+
const { text: desc, flagged } = sanitizeDescription(
|
|
197
|
+
String(parsed.description ?? ''),
|
|
198
|
+
'',
|
|
199
|
+
);
|
|
200
|
+
if (name && desc && !flagged)
|
|
201
|
+
named = { name, description: desc, source: 'mcp-sampling' };
|
|
202
|
+
} catch {
|
|
203
|
+
/* graceful degradation: deterministic naming below */
|
|
204
|
+
}
|
|
132
205
|
}
|
|
133
206
|
const comp = named ?? fallbackComposite(circuit);
|
|
134
207
|
comp.name = uniqueCompositeName(comp.name);
|
|
@@ -137,10 +210,18 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
137
210
|
persistLabs(manifestPath, manifest);
|
|
138
211
|
composites.set(comp.name, { sig, circuit });
|
|
139
212
|
await server.sendToolListChanged().catch(() => {});
|
|
140
|
-
await server
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
213
|
+
await server
|
|
214
|
+
.sendLoggingMessage({
|
|
215
|
+
level: 'info',
|
|
216
|
+
logger: 'sparda',
|
|
217
|
+
data: {
|
|
218
|
+
source: 'condenser',
|
|
219
|
+
circuit: sig,
|
|
220
|
+
composite: comp.name,
|
|
221
|
+
note: `circuit observed ${circuit.seen}× — crystallized as composite tool '${comp.name}' (born mid-session, see tools/list)`,
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
.catch(() => {});
|
|
144
225
|
console.error(`[sparda] condenser: circuit ${sig} crystallized as ${comp.name}`);
|
|
145
226
|
}
|
|
146
227
|
|
|
@@ -173,33 +254,46 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
173
254
|
name,
|
|
174
255
|
description: `[Labs circuit ×${circuit.seen}] ${circuit.composite.description}`,
|
|
175
256
|
inputSchema: compositeSchema(circuit, toolSpecs),
|
|
176
|
-
annotations: {
|
|
257
|
+
annotations: {
|
|
258
|
+
readOnlyHint: true,
|
|
259
|
+
destructiveHint: false,
|
|
260
|
+
idempotentHint: true,
|
|
261
|
+
openWorldHint: false,
|
|
262
|
+
}, // GET-only by construction
|
|
177
263
|
})),
|
|
178
264
|
{
|
|
179
265
|
name: 'sparda_info',
|
|
180
|
-
description:
|
|
266
|
+
description:
|
|
267
|
+
'Info about this MCP server. Generated by SPARDA (npx sparda-mcp init) — turn any codebase into an MCP server in 3 minutes. By Residual Labs (residual-labs.fr) — github.com/zyx77550/sparda',
|
|
181
268
|
inputSchema: { type: 'object', properties: {} },
|
|
182
269
|
},
|
|
183
270
|
{
|
|
184
271
|
name: 'sparda_list_disabled_tools',
|
|
185
|
-
description:
|
|
272
|
+
description:
|
|
273
|
+
'Lists write tools (POST/PUT/DELETE) disabled by SPARDA write-safety, and how to enable them.',
|
|
186
274
|
inputSchema: { type: 'object', properties: {} },
|
|
187
275
|
},
|
|
188
276
|
{
|
|
189
277
|
name: 'sparda_get_context',
|
|
190
|
-
description:
|
|
278
|
+
description:
|
|
279
|
+
'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.',
|
|
191
280
|
inputSchema: { type: 'object', properties: {} },
|
|
192
281
|
},
|
|
193
282
|
{
|
|
194
283
|
name: 'sparda_confirm',
|
|
195
|
-
description:
|
|
284
|
+
description:
|
|
285
|
+
'Confirms a pending write or delete operation gated by human-in-the-loop policies using its confirmation token.',
|
|
196
286
|
inputSchema: {
|
|
197
287
|
type: 'object',
|
|
198
288
|
properties: {
|
|
199
|
-
token: {
|
|
289
|
+
token: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description:
|
|
292
|
+
'The confirmation token returned by the gated invoke response.',
|
|
293
|
+
},
|
|
200
294
|
},
|
|
201
|
-
required: ['token']
|
|
202
|
-
}
|
|
295
|
+
required: ['token'],
|
|
296
|
+
},
|
|
203
297
|
},
|
|
204
298
|
],
|
|
205
299
|
}));
|
|
@@ -212,17 +306,21 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
212
306
|
}));
|
|
213
307
|
|
|
214
308
|
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
215
|
-
const w = (manifest.semantic?.workflows ?? []).find(
|
|
309
|
+
const w = (manifest.semantic?.workflows ?? []).find(
|
|
310
|
+
(x) => x.name === req.params.name,
|
|
311
|
+
);
|
|
216
312
|
if (!w) throw new Error(`unknown prompt: ${req.params.name}`);
|
|
217
313
|
return {
|
|
218
314
|
description: w.description,
|
|
219
|
-
messages: [
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
315
|
+
messages: [
|
|
316
|
+
{
|
|
317
|
+
role: 'user',
|
|
318
|
+
content: {
|
|
319
|
+
type: 'text',
|
|
320
|
+
text: `Goal: ${w.description}\n\nUse the available SPARDA tools in this order, adapting arguments from each result:\n${w.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`,
|
|
321
|
+
},
|
|
224
322
|
},
|
|
225
|
-
|
|
323
|
+
],
|
|
226
324
|
};
|
|
227
325
|
});
|
|
228
326
|
|
|
@@ -230,64 +328,121 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
230
328
|
const { name, arguments: args = {} } = req.params;
|
|
231
329
|
|
|
232
330
|
if (name === 'sparda_info') {
|
|
233
|
-
return text(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
331
|
+
return text(
|
|
332
|
+
JSON.stringify(
|
|
333
|
+
{
|
|
334
|
+
project: path.basename(cwd),
|
|
335
|
+
framework: manifest.framework,
|
|
336
|
+
tools_enabled: enabled().length,
|
|
337
|
+
tools_disabled_write_safety: disabled().length,
|
|
338
|
+
workflows: (manifest.semantic?.workflows ?? []).length,
|
|
339
|
+
immune_antibodies: Object.keys(manifest.immune?.antibodies ?? {}).length,
|
|
340
|
+
labs_sequence_recording: recorder
|
|
341
|
+
? 'on'
|
|
342
|
+
: 'off (opt-in: set labs.recordSequences=true in sparda.json)',
|
|
343
|
+
circuits_observed: Object.keys(manifest.labs?.circuits ?? {}).length,
|
|
344
|
+
composite_tools: composites.size,
|
|
345
|
+
generated_by:
|
|
346
|
+
'SPARDA by Residual Labs (residual-labs.fr) — npx sparda-mcp init — github.com/zyx77550/sparda',
|
|
347
|
+
},
|
|
348
|
+
null,
|
|
349
|
+
2,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
243
352
|
}
|
|
244
353
|
if (name === 'sparda_get_context') {
|
|
245
354
|
const headers = hostHeaders(key, ctx);
|
|
246
355
|
const [stats, events] = await Promise.all([
|
|
247
|
-
fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) })
|
|
248
|
-
|
|
356
|
+
fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) })
|
|
357
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
358
|
+
.catch(() => null),
|
|
359
|
+
fetch(`${base}/mcp/events?since=0`, {
|
|
360
|
+
headers,
|
|
361
|
+
signal: AbortSignal.timeout(2000),
|
|
362
|
+
})
|
|
363
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
364
|
+
.catch(() => null),
|
|
249
365
|
]);
|
|
250
366
|
// lifetime savings are derived from antibody hits — no extra state to maintain:
|
|
251
367
|
// the first hit paid the diagnosis, every later one was served from memory
|
|
252
|
-
const antibodyHits = Object.values(manifest.immune?.antibodies ?? {})
|
|
253
|
-
|
|
368
|
+
const antibodyHits = Object.values(manifest.immune?.antibodies ?? {}).reduce(
|
|
369
|
+
(n, a) => n + Math.max(0, (a.hits ?? 1) - 1),
|
|
370
|
+
0,
|
|
371
|
+
);
|
|
254
372
|
const behavior = engine.snapshot();
|
|
255
373
|
const fly = behavior.flywheel.stats;
|
|
256
|
-
return text(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
374
|
+
return text(
|
|
375
|
+
JSON.stringify(
|
|
376
|
+
{
|
|
377
|
+
project: path.basename(cwd),
|
|
378
|
+
framework: manifest.framework,
|
|
379
|
+
port,
|
|
380
|
+
tools: Object.fromEntries(
|
|
381
|
+
Object.entries(toolSpecs).map(([n, t]) => [
|
|
382
|
+
n,
|
|
383
|
+
{
|
|
384
|
+
method: t.method,
|
|
385
|
+
path: t.path,
|
|
386
|
+
enabled: t.enabled,
|
|
387
|
+
description: descFor(n, t),
|
|
388
|
+
},
|
|
389
|
+
]),
|
|
390
|
+
),
|
|
391
|
+
workflows: manifest.semantic?.workflows ?? [],
|
|
392
|
+
runtime: stats,
|
|
393
|
+
recentEvents: (events?.events ?? []).slice(-20),
|
|
394
|
+
immuneMemory: manifest.immune?.antibodies ?? {},
|
|
395
|
+
recycling: {
|
|
396
|
+
compute: stats?.recycle ?? null,
|
|
397
|
+
// R4.3: host calls the flywheel answered from its own RAM — a category the
|
|
398
|
+
// router cannot count, because the request never reached it. armed = reads
|
|
399
|
+
// proven stable enough to serve right now.
|
|
400
|
+
flywheel: { servedFromMemory: fly.served, armed: fly.ready },
|
|
401
|
+
intelligence: {
|
|
402
|
+
session: {
|
|
403
|
+
samplingAvoided: intel.samplingAvoided,
|
|
404
|
+
tokensAvoidedEst: intel.tokensAvoidedEst,
|
|
405
|
+
},
|
|
406
|
+
lifetime: {
|
|
407
|
+
antibodyHits,
|
|
408
|
+
tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
sparding: manifest.sparding ?? {},
|
|
413
|
+
behavior,
|
|
414
|
+
labs: {
|
|
415
|
+
recordSequences: Boolean(recorder),
|
|
416
|
+
compositeTools: [...composites.keys()],
|
|
417
|
+
circuits: manifest.labs?.circuits ?? {},
|
|
418
|
+
},
|
|
419
|
+
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).",
|
|
274
420
|
},
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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).',
|
|
280
|
-
}, null, 2));
|
|
421
|
+
null,
|
|
422
|
+
2,
|
|
423
|
+
),
|
|
424
|
+
);
|
|
281
425
|
}
|
|
282
426
|
if (name === 'sparda_list_disabled_tools') {
|
|
283
|
-
return text(
|
|
284
|
-
|
|
285
|
-
|
|
427
|
+
return text(
|
|
428
|
+
disabled().length
|
|
429
|
+
? `Disabled (write-safety):\n${disabled()
|
|
430
|
+
.map(([n, t]) => `- ${n} (${t.method} ${t.path})`)
|
|
431
|
+
.join(
|
|
432
|
+
'\n',
|
|
433
|
+
)}\n\nTo enable: set "enabled": true in sparda.json, then re-run \`npx sparda-mcp init\` and restart this bridge.`
|
|
434
|
+
: 'No disabled tools.',
|
|
435
|
+
);
|
|
286
436
|
}
|
|
287
437
|
if (name === 'sparda_confirm') {
|
|
288
438
|
const token = args.token;
|
|
289
439
|
if (typeof token !== 'string' || !token) {
|
|
290
|
-
return {
|
|
440
|
+
return {
|
|
441
|
+
content: [
|
|
442
|
+
{ type: 'text', text: 'Error: missing or invalid confirmation token.' },
|
|
443
|
+
],
|
|
444
|
+
isError: true,
|
|
445
|
+
};
|
|
291
446
|
}
|
|
292
447
|
// SIGNAL 2 (Brief #1): the AI holds the token (Signal 1, reachable over stdio) but the
|
|
293
448
|
// write proceeds only if a human approved out-of-band — an OS-dialog click, or a prior
|
|
@@ -295,14 +450,28 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
295
450
|
// confirm can no longer pass. Necessary-but-not-sufficient by construction.
|
|
296
451
|
const gate = confirmWrite(token);
|
|
297
452
|
if (!gate.ok) {
|
|
298
|
-
return {
|
|
453
|
+
return {
|
|
454
|
+
content: [{ type: 'text', text: signal2Denial(gate.reason) }],
|
|
455
|
+
isError: true,
|
|
456
|
+
};
|
|
299
457
|
}
|
|
300
458
|
const payload = await confirmInvoke(base, key, token, ctx);
|
|
301
459
|
if (payload === null) {
|
|
302
|
-
return {
|
|
460
|
+
return {
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: 'text',
|
|
464
|
+
text: `Host app error. Check that your server is still running on :${port}.`,
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
isError: true,
|
|
468
|
+
};
|
|
303
469
|
}
|
|
304
470
|
const pretty = JSON.stringify(payload, null, 2);
|
|
305
|
-
const isError =
|
|
471
|
+
const isError =
|
|
472
|
+
payload.upstreamStatus !== undefined
|
|
473
|
+
? payload.upstreamStatus >= 400
|
|
474
|
+
: Boolean(payload.error);
|
|
306
475
|
return { content: [{ type: 'text', text: pretty }], isError };
|
|
307
476
|
}
|
|
308
477
|
|
|
@@ -310,10 +479,23 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
310
479
|
// write confirmation to bypass), auto-feeding linked args between steps
|
|
311
480
|
const comp = composites.get(name);
|
|
312
481
|
if (comp) {
|
|
313
|
-
const result = await runComposite({
|
|
482
|
+
const result = await runComposite({
|
|
483
|
+
circuit: comp.circuit,
|
|
484
|
+
args,
|
|
485
|
+
toolSpecs,
|
|
486
|
+
invokeFn: (tool, a) => invoke(base, key, tool, a, ctx),
|
|
487
|
+
});
|
|
314
488
|
const pretty = JSON.stringify(result, null, 2);
|
|
315
489
|
return {
|
|
316
|
-
content: [
|
|
490
|
+
content: [
|
|
491
|
+
{
|
|
492
|
+
type: 'text',
|
|
493
|
+
text:
|
|
494
|
+
pretty.length > 8000
|
|
495
|
+
? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]`
|
|
496
|
+
: pretty,
|
|
497
|
+
},
|
|
498
|
+
],
|
|
317
499
|
isError: !result.ok,
|
|
318
500
|
};
|
|
319
501
|
}
|
|
@@ -326,14 +508,18 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
326
508
|
// host's confirm round-trip never prompts the operator a second time.
|
|
327
509
|
let elicitationApproved = false;
|
|
328
510
|
if (isWrite && server.getClientCapabilities()?.elicitation) {
|
|
329
|
-
const answer = await server
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
511
|
+
const answer = await server
|
|
512
|
+
.elicitInput({
|
|
513
|
+
message: `SPARDA: allow ${spec.method} ${spec.path}? This is a write operation on your live app.`,
|
|
514
|
+
requestedSchema: {
|
|
515
|
+
type: 'object',
|
|
516
|
+
properties: {
|
|
517
|
+
confirm: { type: 'boolean', title: `Allow ${spec.method} ${spec.path}` },
|
|
518
|
+
},
|
|
519
|
+
required: ['confirm'],
|
|
520
|
+
},
|
|
521
|
+
})
|
|
522
|
+
.catch(() => null);
|
|
337
523
|
if (!answer || answer.action !== 'accept' || answer.content?.confirm !== true) {
|
|
338
524
|
const mockProof = {
|
|
339
525
|
version: 'sparding-proof/v0.1',
|
|
@@ -342,7 +528,15 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
342
528
|
reasons: ['Write declined by user'],
|
|
343
529
|
};
|
|
344
530
|
recordSparding(manifestPath, manifest, name, mockProof);
|
|
345
|
-
return {
|
|
531
|
+
return {
|
|
532
|
+
content: [
|
|
533
|
+
{
|
|
534
|
+
type: 'text',
|
|
535
|
+
text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.`,
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
isError: true,
|
|
539
|
+
};
|
|
346
540
|
}
|
|
347
541
|
elicitationApproved = true;
|
|
348
542
|
}
|
|
@@ -358,7 +552,10 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
358
552
|
if (cached.hit) {
|
|
359
553
|
const body = { data: cached.value, upstreamStatus: 200, servedByFlywheel: true };
|
|
360
554
|
const pretty = JSON.stringify(body, null, 2);
|
|
361
|
-
const truncated =
|
|
555
|
+
const truncated =
|
|
556
|
+
pretty.length > 8000
|
|
557
|
+
? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]`
|
|
558
|
+
: pretty;
|
|
362
559
|
return { content: [{ type: 'text', text: truncated }], isError: false };
|
|
363
560
|
}
|
|
364
561
|
}
|
|
@@ -372,7 +569,15 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
372
569
|
reasons: ['Host app error or unreachable'],
|
|
373
570
|
};
|
|
374
571
|
recordSparding(manifestPath, manifest, name, mockProof);
|
|
375
|
-
return {
|
|
572
|
+
return {
|
|
573
|
+
content: [
|
|
574
|
+
{
|
|
575
|
+
type: 'text',
|
|
576
|
+
text: `Host app error. Check that your server is still running on :${port}.`,
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
isError: true,
|
|
580
|
+
};
|
|
376
581
|
}
|
|
377
582
|
|
|
378
583
|
// human-in-the-loop, channel 2 (Brief #1): the host gated this write and minted a single-use
|
|
@@ -380,18 +585,31 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
380
585
|
// human yes → pre-approve (no second prompt). Everyone else gets an OS dialog (fires async,
|
|
381
586
|
// R1) that `sparda_confirm` will require before the write can run. This is what closes the
|
|
382
587
|
// forgeable-confirmation hole on clients without elicitation.
|
|
383
|
-
if (
|
|
588
|
+
if (
|
|
589
|
+
isWrite &&
|
|
590
|
+
payload.status === 'awaiting_confirmation' &&
|
|
591
|
+
typeof payload.confirm === 'string'
|
|
592
|
+
) {
|
|
384
593
|
if (elicitationApproved) preapproveWrite(payload.confirm);
|
|
385
|
-
else
|
|
594
|
+
else
|
|
595
|
+
initiateWrite({ token: payload.confirm, label: `${spec.method} ${spec.path}` });
|
|
386
596
|
}
|
|
387
597
|
|
|
388
|
-
const proofForRecord = payload.spardingProof
|
|
598
|
+
const proofForRecord = payload.spardingProof
|
|
599
|
+
? { ...payload.spardingProof }
|
|
600
|
+
: { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
|
|
389
601
|
if (payload.upstreamStatus !== undefined && payload.upstreamStatus >= 400) {
|
|
390
602
|
proofForRecord.isExecutionError = true;
|
|
391
|
-
proofForRecord.reasons = [
|
|
603
|
+
proofForRecord.reasons = [
|
|
604
|
+
...(proofForRecord.reasons || []),
|
|
605
|
+
`upstream error status ${payload.upstreamStatus}`,
|
|
606
|
+
];
|
|
392
607
|
} else if (payload.error) {
|
|
393
608
|
proofForRecord.isExecutionError = true;
|
|
394
|
-
proofForRecord.reasons = [
|
|
609
|
+
proofForRecord.reasons = [
|
|
610
|
+
...(proofForRecord.reasons || []),
|
|
611
|
+
`invocation error: ${payload.error}`,
|
|
612
|
+
];
|
|
395
613
|
}
|
|
396
614
|
recordSparding(manifestPath, manifest, name, proofForRecord);
|
|
397
615
|
|
|
@@ -404,7 +622,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
404
622
|
// here on the hot path; classify in idle. (The proof-after-write read-back below
|
|
405
623
|
// is internal, not an AI workflow step, so it is deliberately not observed.)
|
|
406
624
|
const observedAt = Date.now();
|
|
407
|
-
harvester.enqueue(() =>
|
|
625
|
+
harvester.enqueue(() =>
|
|
626
|
+
engine.observe(name, payload.data, observedAt, isWrite, args),
|
|
627
|
+
);
|
|
408
628
|
// condenser tap (Labs, opt-in): detect circuits where one output feeds the next
|
|
409
629
|
if (recorder) recorder.record(name, args, payload.data);
|
|
410
630
|
}
|
|
@@ -419,27 +639,41 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
419
639
|
for (const [n, t] of Object.entries(toolSpecs)) {
|
|
420
640
|
if (t.method === 'GET' && t.path === spec.path) engine.invalidateCache(n);
|
|
421
641
|
}
|
|
422
|
-
const getter = Object.entries(toolSpecs).find(
|
|
642
|
+
const getter = Object.entries(toolSpecs).find(
|
|
643
|
+
([, t]) => t.enabled && t.method === 'GET' && t.path === spec.path,
|
|
644
|
+
);
|
|
423
645
|
if (getter) {
|
|
424
646
|
const after = await invoke(base, key, getter[0], pickPathArgs(spec, args), ctx);
|
|
425
|
-
if (after && after.upstreamStatus < 400)
|
|
647
|
+
if (after && after.upstreamStatus < 400)
|
|
648
|
+
proof = { readBack: getter[0], state: after.data };
|
|
426
649
|
}
|
|
427
650
|
}
|
|
428
651
|
|
|
429
652
|
const body = proof ? { ...payload, proof } : payload;
|
|
430
653
|
const pretty = JSON.stringify(body, null, 2);
|
|
431
|
-
const truncated =
|
|
654
|
+
const truncated =
|
|
655
|
+
pretty.length > 8000
|
|
656
|
+
? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]`
|
|
657
|
+
: pretty;
|
|
432
658
|
// router-level rejections (quarantine, disabled tool, bad params) carry `error` and no upstreamStatus
|
|
433
|
-
const isError =
|
|
659
|
+
const isError =
|
|
660
|
+
payload.upstreamStatus !== undefined
|
|
661
|
+
? payload.upstreamStatus >= 400
|
|
662
|
+
: Boolean(payload.error);
|
|
434
663
|
return { content: [{ type: 'text', text: truncated }], isError };
|
|
435
664
|
});
|
|
436
665
|
|
|
437
666
|
let pollTimer = null;
|
|
438
667
|
server.oninitialized = () => {
|
|
439
668
|
// a session resumed on a cached semantic pass skipped the enrichment sampling call
|
|
440
|
-
if (manifest.semantic) {
|
|
669
|
+
if (manifest.semantic) {
|
|
670
|
+
intel.samplingAvoided += 1;
|
|
671
|
+
intel.tokensAvoidedEst += SEMANTIC_TOKENS;
|
|
672
|
+
}
|
|
441
673
|
pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel, ctx);
|
|
442
|
-
runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) =>
|
|
674
|
+
runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) =>
|
|
675
|
+
console.error(`[sparda] semantic pass skipped: ${e.message}`),
|
|
676
|
+
);
|
|
443
677
|
};
|
|
444
678
|
server.onclose = () => {
|
|
445
679
|
if (pollTimer) clearInterval(pollTimer);
|
|
@@ -449,7 +683,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
449
683
|
|
|
450
684
|
const transport = new StdioServerTransport();
|
|
451
685
|
await server.connect(transport);
|
|
452
|
-
console.error(
|
|
686
|
+
console.error(
|
|
687
|
+
`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`,
|
|
688
|
+
);
|
|
453
689
|
}
|
|
454
690
|
|
|
455
691
|
// One place to build the outbound header set for any bridge→host call. The
|
|
@@ -471,7 +707,10 @@ async function invoke(base, key, tool, args, ctx) {
|
|
|
471
707
|
body: JSON.stringify({ tool, args }),
|
|
472
708
|
signal: AbortSignal.timeout(30_000),
|
|
473
709
|
});
|
|
474
|
-
return await res.json().catch(() => ({
|
|
710
|
+
return await res.json().catch(() => ({
|
|
711
|
+
upstreamStatus: res.status,
|
|
712
|
+
error: 'non-JSON response from host',
|
|
713
|
+
}));
|
|
475
714
|
} catch {
|
|
476
715
|
return null;
|
|
477
716
|
}
|
|
@@ -485,7 +724,10 @@ async function confirmInvoke(base, key, token, ctx) {
|
|
|
485
724
|
body: JSON.stringify({ confirm: token }),
|
|
486
725
|
signal: AbortSignal.timeout(30_000),
|
|
487
726
|
});
|
|
488
|
-
return await res.json().catch(() => ({
|
|
727
|
+
return await res.json().catch(() => ({
|
|
728
|
+
upstreamStatus: res.status,
|
|
729
|
+
error: 'non-JSON response from host',
|
|
730
|
+
}));
|
|
489
731
|
} catch {
|
|
490
732
|
return null;
|
|
491
733
|
}
|
|
@@ -505,10 +747,16 @@ function startEventPolling(server, base, key, manifest, manifestPath, intel, ctx
|
|
|
505
747
|
let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
|
|
506
748
|
const timer = setInterval(async () => {
|
|
507
749
|
try {
|
|
508
|
-
const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, {
|
|
750
|
+
const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, {
|
|
751
|
+
headers: hostHeaders(key, ctx),
|
|
752
|
+
signal: AbortSignal.timeout(2000),
|
|
753
|
+
});
|
|
509
754
|
if (!r.ok) return;
|
|
510
755
|
const { seq, events } = await r.json();
|
|
511
|
-
if (lastSeq === null) {
|
|
756
|
+
if (lastSeq === null) {
|
|
757
|
+
lastSeq = seq;
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
512
760
|
lastSeq = Math.max(lastSeq, seq);
|
|
513
761
|
for (const ev of events) {
|
|
514
762
|
const sig = `${ev.source}|${ev.tool ?? ''}|${ev.status ?? ''}`;
|
|
@@ -516,16 +764,28 @@ function startEventPolling(server, base, key, manifest, manifestPath, intel, ctx
|
|
|
516
764
|
if (antibody) {
|
|
517
765
|
antibody.hits = (antibody.hits ?? 0) + 1;
|
|
518
766
|
antibody.lastSeen = ev.ts;
|
|
519
|
-
intel.samplingAvoided += 1;
|
|
767
|
+
intel.samplingAvoided += 1; // this diagnosis would have been a sampling call
|
|
520
768
|
intel.tokensAvoidedEst += DIAGNOSIS_TOKENS;
|
|
521
769
|
persistImmune(manifestPath, manifest);
|
|
522
|
-
await server
|
|
770
|
+
await server
|
|
771
|
+
.sendLoggingMessage({
|
|
772
|
+
level: 'error',
|
|
773
|
+
logger: 'sparda',
|
|
774
|
+
data: { ...ev, diagnosis: antibody.diagnosis },
|
|
775
|
+
})
|
|
776
|
+
.catch(() => {});
|
|
523
777
|
} else {
|
|
524
|
-
await server
|
|
525
|
-
|
|
778
|
+
await server
|
|
779
|
+
.sendLoggingMessage({ level: 'error', logger: 'sparda', data: ev })
|
|
780
|
+
.catch(() => {});
|
|
781
|
+
diagnoseAndRemember({ server, manifest, manifestPath, ev, sig }).catch(
|
|
782
|
+
() => {},
|
|
783
|
+
);
|
|
526
784
|
}
|
|
527
785
|
}
|
|
528
|
-
} catch {
|
|
786
|
+
} catch {
|
|
787
|
+
/* host briefly unreachable — next tick */
|
|
788
|
+
}
|
|
529
789
|
}, EVENT_POLL_MS);
|
|
530
790
|
timer.unref?.();
|
|
531
791
|
return timer;
|
|
@@ -544,13 +804,15 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
|
|
|
544
804
|
diagnosing.add(sig);
|
|
545
805
|
try {
|
|
546
806
|
const res = await server.createMessage({
|
|
547
|
-
messages: [
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
807
|
+
messages: [
|
|
808
|
+
{
|
|
809
|
+
role: 'user',
|
|
810
|
+
content: {
|
|
811
|
+
type: 'text',
|
|
812
|
+
text: `A tool of a live ${manifest.framework} app just failed.\nTool: ${ev.tool ?? 'n/a'}\nSource: ${ev.source}\nStatus: ${ev.status ?? 'n/a'}\nError message: ${ev.message}\n\nReply with ONE short sentence (max 140 chars): most likely root cause and fix direction. No preamble.`,
|
|
813
|
+
},
|
|
552
814
|
},
|
|
553
|
-
|
|
815
|
+
],
|
|
554
816
|
maxTokens: DIAGNOSIS_TOKENS,
|
|
555
817
|
});
|
|
556
818
|
const raw = res?.content?.type === 'text' ? res.content.text.trim() : '';
|
|
@@ -558,10 +820,18 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
|
|
|
558
820
|
if (!clean || flagged) return;
|
|
559
821
|
antibodies[sig] = { diagnosis: clean, firstSeen: ev.ts, lastSeen: ev.ts, hits: 1 };
|
|
560
822
|
persistImmune(manifestPath, manifest);
|
|
561
|
-
await server
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
823
|
+
await server
|
|
824
|
+
.sendLoggingMessage({
|
|
825
|
+
level: 'info',
|
|
826
|
+
logger: 'sparda',
|
|
827
|
+
data: {
|
|
828
|
+
source: 'immune',
|
|
829
|
+
signature: sig,
|
|
830
|
+
diagnosis: clean,
|
|
831
|
+
note: 'antibody stored in sparda.json — the same failure will be diagnosed instantly, zero tokens',
|
|
832
|
+
},
|
|
833
|
+
})
|
|
834
|
+
.catch(() => {});
|
|
565
835
|
console.error(`[sparda] immune: antibody stored for ${sig}`);
|
|
566
836
|
} finally {
|
|
567
837
|
diagnosing.delete(sig);
|
|
@@ -577,7 +847,13 @@ function persistImmune(manifestPath, manifest) {
|
|
|
577
847
|
|
|
578
848
|
function recordSparding(manifestPath, manifest, tool, proof) {
|
|
579
849
|
try {
|
|
580
|
-
manifest.sparding ??= {
|
|
850
|
+
manifest.sparding ??= {
|
|
851
|
+
version: 1,
|
|
852
|
+
policies: {},
|
|
853
|
+
events: [],
|
|
854
|
+
failures: {},
|
|
855
|
+
toolFingerprints: {},
|
|
856
|
+
};
|
|
581
857
|
manifest.sparding.events ??= [];
|
|
582
858
|
manifest.sparding.failures ??= {};
|
|
583
859
|
|
|
@@ -596,16 +872,22 @@ function recordSparding(manifestPath, manifest, tool, proof) {
|
|
|
596
872
|
const isBlock = proof.decision === 'block';
|
|
597
873
|
const isError = proof.isExecutionError;
|
|
598
874
|
if (isBlock || isError) {
|
|
599
|
-
const reasonCode =
|
|
875
|
+
const reasonCode =
|
|
876
|
+
proof.reasons && proof.reasons[0]
|
|
877
|
+
? proof.reasons[0].replace(/\s+/g, '_').toLowerCase()
|
|
878
|
+
: 'unknown_error';
|
|
600
879
|
const sig = `${tool}|${reasonCode}`;
|
|
601
880
|
const prevFail = manifest.sparding.failures[sig] || { count: 0, lesson: '' };
|
|
602
|
-
|
|
881
|
+
|
|
603
882
|
let lesson = `Execution failed for tool: ${tool}.`;
|
|
604
883
|
if (reasonCode.includes('quarantined')) {
|
|
605
884
|
lesson = `Tool ${tool} was quarantined due to repeated failures.`;
|
|
606
885
|
} else if (reasonCode.includes('disabled')) {
|
|
607
886
|
lesson = `Tool ${tool} is disabled by write-safety policies.`;
|
|
608
|
-
} else if (
|
|
887
|
+
} else if (
|
|
888
|
+
reasonCode.includes('missing_path_param') ||
|
|
889
|
+
reasonCode.includes('missing_path')
|
|
890
|
+
) {
|
|
609
891
|
lesson = `Client omitted a required path parameter on route ${tool}.`;
|
|
610
892
|
} else if (reasonCode.includes('declined')) {
|
|
611
893
|
lesson = `Human user declined confirmation for write tool ${tool}.`;
|
|
@@ -638,7 +920,9 @@ function persistLabs(manifestPath, manifest) {
|
|
|
638
920
|
const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
639
921
|
onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
|
|
640
922
|
writeManifestSync(manifestPath, onDisk);
|
|
641
|
-
} catch {
|
|
923
|
+
} catch {
|
|
924
|
+
/* disk briefly unavailable — the circuit stays in memory */
|
|
925
|
+
}
|
|
642
926
|
}
|
|
643
927
|
|
|
644
928
|
// semantic pass: uses the CLIENT's own model via MCP sampling — zero key, zero cost.
|
|
@@ -646,16 +930,22 @@ function persistLabs(manifestPath, manifest) {
|
|
|
646
930
|
async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }) {
|
|
647
931
|
if (manifest.semantic || !server.getClientCapabilities()?.sampling) return;
|
|
648
932
|
|
|
649
|
-
const inventory = Object.entries(toolSpecs)
|
|
650
|
-
|
|
933
|
+
const inventory = Object.entries(toolSpecs)
|
|
934
|
+
.map(
|
|
935
|
+
([n, t]) =>
|
|
936
|
+
`- ${n}: ${t.method} ${t.path}${t.description ? ` — ${t.description}` : ''}`,
|
|
937
|
+
)
|
|
938
|
+
.join('\n');
|
|
651
939
|
const res = await server.createMessage({
|
|
652
|
-
messages: [
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
940
|
+
messages: [
|
|
941
|
+
{
|
|
942
|
+
role: 'user',
|
|
943
|
+
content: {
|
|
944
|
+
type: 'text',
|
|
945
|
+
text: `These are API tools extracted from a ${manifest.framework} codebase:\n${inventory}\n\nReply with ONLY a JSON object, no prose, shaped as {"descriptions": {"<tool>": "<one clear sentence of what it does in business terms>"}, "workflows": [{"name": "<snake_case>", "description": "<goal>", "steps": ["<tool>", ...]}]}. Include 1-3 workflows that chain tools toward a realistic business goal. Use only the tool names listed above.`,
|
|
946
|
+
},
|
|
657
947
|
},
|
|
658
|
-
|
|
948
|
+
],
|
|
659
949
|
maxTokens: SEMANTIC_TOKENS,
|
|
660
950
|
});
|
|
661
951
|
|
|
@@ -669,21 +959,37 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
|
|
|
669
959
|
if (clean && !flagged) descriptions[n] = clean;
|
|
670
960
|
}
|
|
671
961
|
const workflows = (Array.isArray(parsed.workflows) ? parsed.workflows : [])
|
|
672
|
-
.filter(
|
|
962
|
+
.filter(
|
|
963
|
+
(w) =>
|
|
964
|
+
w &&
|
|
965
|
+
typeof w.name === 'string' &&
|
|
966
|
+
typeof w.description === 'string' &&
|
|
967
|
+
Array.isArray(w.steps),
|
|
968
|
+
)
|
|
673
969
|
.slice(0, 5)
|
|
674
970
|
.map((w) => ({
|
|
675
|
-
name: w.name
|
|
971
|
+
name: w.name
|
|
972
|
+
.toLowerCase()
|
|
973
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
974
|
+
.slice(0, 60),
|
|
676
975
|
description: sanitizeDescription(w.description, 'workflow').text,
|
|
677
976
|
steps: w.steps.filter((s) => typeof s === 'string' && toolSpecs[s]),
|
|
678
977
|
}))
|
|
679
978
|
.filter((w) => w.steps.length > 0);
|
|
680
979
|
|
|
681
|
-
manifest.semantic = {
|
|
980
|
+
manifest.semantic = {
|
|
981
|
+
enrichedAt: new Date().toISOString(),
|
|
982
|
+
source: 'mcp-sampling',
|
|
983
|
+
descriptions,
|
|
984
|
+
workflows,
|
|
985
|
+
};
|
|
682
986
|
mergeManifestKeySync(manifestPath, 'semantic', manifest.semantic);
|
|
683
987
|
|
|
684
988
|
await server.sendToolListChanged().catch(() => {});
|
|
685
989
|
await server.sendPromptListChanged().catch(() => {});
|
|
686
|
-
console.error(
|
|
990
|
+
console.error(
|
|
991
|
+
`[sparda] semantic pass done: ${Object.keys(descriptions).length} descriptions, ${workflows.length} workflows (cached in sparda.json)`,
|
|
992
|
+
);
|
|
687
993
|
}
|
|
688
994
|
|
|
689
995
|
// MCP annotations: without them clients assume the scariest defaults and show
|
|
@@ -698,15 +1004,21 @@ function annotationsFor(method) {
|
|
|
698
1004
|
}
|
|
699
1005
|
|
|
700
1006
|
function schemaFor(t) {
|
|
701
|
-
const properties = {};
|
|
1007
|
+
const properties = {};
|
|
1008
|
+
const required = [];
|
|
702
1009
|
for (const p of t.params ?? []) {
|
|
703
|
-
properties[p.name] = {
|
|
1010
|
+
properties[p.name] = {
|
|
1011
|
+
type: p.type === 'unknown' ? 'string' : p.type,
|
|
1012
|
+
description: p.description ?? p.in,
|
|
1013
|
+
};
|
|
704
1014
|
if (p.required) required.push(p.name);
|
|
705
1015
|
}
|
|
706
1016
|
return { type: 'object', properties, ...(required.length ? { required } : {}) };
|
|
707
1017
|
}
|
|
708
1018
|
|
|
709
|
-
function text(t) {
|
|
1019
|
+
function text(t) {
|
|
1020
|
+
return { content: [{ type: 'text', text: t }] };
|
|
1021
|
+
}
|
|
710
1022
|
|
|
711
1023
|
// AI-facing message when the Signal-2 gate (Brief #1) refuses a `sparda_confirm`. Each reason
|
|
712
1024
|
// tells the model what to do next without implying it can approve the write itself.
|
|
@@ -730,16 +1042,32 @@ async function waitForHost(port, key, framework, ctx) {
|
|
|
730
1042
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
731
1043
|
for (const h of hosts) {
|
|
732
1044
|
try {
|
|
733
|
-
const r = await fetch(`http://${h}:${port}/mcp/tools`, {
|
|
1045
|
+
const r = await fetch(`http://${h}:${port}/mcp/tools`, {
|
|
1046
|
+
headers: hostHeaders(key, ctx),
|
|
1047
|
+
signal: AbortSignal.timeout(1500),
|
|
1048
|
+
});
|
|
734
1049
|
if (r.ok) return `http://${h}:${port}`;
|
|
735
|
-
if (r.status === 401)
|
|
736
|
-
|
|
1050
|
+
if (r.status === 401)
|
|
1051
|
+
throw Object.assign(
|
|
1052
|
+
new Error(
|
|
1053
|
+
'Host app reachable but key mismatch — re-run `npx sparda-mcp init`.',
|
|
1054
|
+
),
|
|
1055
|
+
{ code: 'USER' },
|
|
1056
|
+
);
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
if (e.code === 'USER') throw e;
|
|
1059
|
+
}
|
|
737
1060
|
}
|
|
738
1061
|
if (attempt === 0) {
|
|
739
1062
|
const startCmd = framework === 'express' ? 'npm run dev' : 'fastapi dev';
|
|
740
|
-
console.error(
|
|
1063
|
+
console.error(
|
|
1064
|
+
`[sparda] Waiting for host app on :${port} ... (start it with ${startCmd} — Ctrl+C to abort)`,
|
|
1065
|
+
);
|
|
741
1066
|
}
|
|
742
1067
|
await new Promise((r) => setTimeout(r, 3000));
|
|
743
1068
|
}
|
|
744
|
-
throw Object.assign(new Error(`Host app unreachable on :${port} after 2 minutes.`), {
|
|
1069
|
+
throw Object.assign(new Error(`Host app unreachable on :${port} after 2 minutes.`), {
|
|
1070
|
+
code: 'USER',
|
|
1071
|
+
hint: 'Start your server first, then run `npx sparda-mcp dev`.',
|
|
1072
|
+
});
|
|
745
1073
|
}
|