sparda-mcp 0.5.0 → 0.5.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/package.json +6 -3
- package/templates/express-router.txt +143 -32
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"sparda": "./src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "vitest run"
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:router": "node tests/router-selftest.cjs",
|
|
12
|
+
"test:all": "vitest run && node tests/router-selftest.cjs"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
14
16
|
"templates",
|
|
17
|
+
"!templates/*.bak",
|
|
15
18
|
"README.md",
|
|
16
19
|
"LICENSE"
|
|
17
20
|
],
|
|
@@ -36,7 +39,7 @@
|
|
|
36
39
|
"@modelcontextprotocol/sdk": "1.29.0"
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
39
|
-
"express": "4.21.
|
|
42
|
+
"express": "^4.21.0",
|
|
40
43
|
"vitest": "^3.2.6"
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -62,6 +62,20 @@ function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, m
|
|
|
62
62
|
// observe-only: uncaughtExceptionMonitor never changes the host app's crash behavior
|
|
63
63
|
process.on('uncaughtExceptionMonitor', (err__ANY_TYPE__) => spardaEvent('process', null, null, err && err.message ? err.message : err));
|
|
64
64
|
|
|
65
|
+
// two-phase commit: a write/delete flagged require_human is NOT executed on /invoke.
|
|
66
|
+
// It mints a single-use nonce + a preview contract; the host route is touched only after
|
|
67
|
+
// an explicit /invoke/confirm replays the token. This is the gate the proof always described
|
|
68
|
+
// but never enforced — the difference between a receipt and a contract.
|
|
69
|
+
const SPARDA_PENDING__STATS_TYPE__ = {};
|
|
70
|
+
const SPARDA_CONFIRM_TTL_MS = Number(process.env.SPARDA_CONFIRM_TTL_MS ?? 120000);
|
|
71
|
+
function spardaNonce() {
|
|
72
|
+
return 'cfm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
73
|
+
}
|
|
74
|
+
function spardaSweepPending() {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
for (const k of Object.keys(SPARDA_PENDING)) if (now > SPARDA_PENDING[k].expiresAt) delete SPARDA_PENDING[k];
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
|
|
66
80
|
const checks = {
|
|
67
81
|
knownTool: spec !== undefined,
|
|
@@ -190,6 +204,53 @@ function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
|
|
|
190
204
|
};
|
|
191
205
|
}
|
|
192
206
|
|
|
207
|
+
// captured once so the JSON-middleware placeholder appears a single time in the template
|
|
208
|
+
// (the generator's replace is non-global).
|
|
209
|
+
const SPARDA_JSON_MW__ANY_TYPE__ = __JSON_MW__;
|
|
210
|
+
// SPARDA owns its own endpoints' body parsing AND the parse error: a malformed JSON body
|
|
211
|
+
// returns JSON 400 locally instead of bubbling up to Express' HTML error page (stack leak).
|
|
212
|
+
function SPARDA_JSON(req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) {
|
|
213
|
+
SPARDA_JSON_MW(req, res, (err__ANY_TYPE__) => {
|
|
214
|
+
if (err) { spardaEvent('router', null, 400, 'invalid JSON body'); return res.status(400).json({ error: 'invalid JSON body' }); }
|
|
215
|
+
next();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// the actual host-route call, shared by the allow-path and the confirm-path so a
|
|
220
|
+
// confirmed write runs through the exact same execution + observation as a direct one.
|
|
221
|
+
async function spardaExecute(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__, proof__ANY_TYPE__, t0__ANY_TYPE__, res__RES_TYPE__) {
|
|
222
|
+
try {
|
|
223
|
+
let url = spec.path.replace(/:(\w+)/g, (_, name) => encodeURIComponent(String(args[name])));
|
|
224
|
+
const query = [];
|
|
225
|
+
for (const [k, v] of Object.entries(args)) {
|
|
226
|
+
if (k === 'body' || (spec.pathParams ?? []).includes(k) || v === undefined) continue;
|
|
227
|
+
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
228
|
+
}
|
|
229
|
+
if (query.length) url += `?${query.join('&')}`;
|
|
230
|
+
|
|
231
|
+
const init = { method: spec.method, headers: {} };
|
|
232
|
+
if (spec.method !== 'GET' && args.body !== undefined) {
|
|
233
|
+
init.headers['content-type'] = 'application/json';
|
|
234
|
+
init.body = JSON.stringify(args.body);
|
|
235
|
+
}
|
|
236
|
+
SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
|
|
237
|
+
const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
238
|
+
const text = await upstream.text();
|
|
239
|
+
let data; try { data = JSON.parse(text); } catch { data = text; }
|
|
240
|
+
spardaRecord(tool, upstream.status, Date.now() - t0);
|
|
241
|
+
if (spec.method === 'GET' && upstream.status === 200) {
|
|
242
|
+
// canonical argsig: sorted entries, so the AI's argument order never splits a signature
|
|
243
|
+
spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
|
|
244
|
+
}
|
|
245
|
+
if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
|
|
246
|
+
return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
|
|
247
|
+
} catch (err__ANY_TYPE__) {
|
|
248
|
+
spardaRecord(tool, err.status ?? 502, Date.now() - t0);
|
|
249
|
+
spardaEvent('invoke', tool, err.status ?? 502, err.message);
|
|
250
|
+
return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
193
254
|
__ROUTER_DECL__
|
|
194
255
|
|
|
195
256
|
spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
|
|
@@ -208,9 +269,17 @@ spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
|
208
269
|
res.json({ seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e__ANY_TYPE__) => e.seq > since) });
|
|
209
270
|
});
|
|
210
271
|
|
|
211
|
-
spardaRouter.post('/invoke',
|
|
212
|
-
const { tool, args = {} } = req.body ?? {};
|
|
272
|
+
spardaRouter.post('/invoke', SPARDA_JSON, async (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
213
273
|
const t0 = Date.now();
|
|
274
|
+
const reqBody = req.body ?? {};
|
|
275
|
+
const tool = reqBody.tool;
|
|
276
|
+
// type confusion guard: the old `args = {}` default only caught `undefined`. A `null`
|
|
277
|
+
// (or array/scalar) slipped through and crashed spardaProof on null[name] → Express HTML
|
|
278
|
+
// 500 with a full stack trace. We reject non-objects with clean JSON instead.
|
|
279
|
+
if (reqBody.args !== undefined && (reqBody.args === null || typeof reqBody.args !== 'object' || Array.isArray(reqBody.args))) {
|
|
280
|
+
return res.status(400).json({ error: 'args must be a JSON object', got: reqBody.args === null ? 'null' : Array.isArray(reqBody.args) ? 'array' : typeof reqBody.args });
|
|
281
|
+
}
|
|
282
|
+
const args = reqBody.args ?? {};
|
|
214
283
|
const spec = SPARDA_TOOLS[tool];
|
|
215
284
|
|
|
216
285
|
const proof = spardaProof(tool, spec, args);
|
|
@@ -249,41 +318,69 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
|
|
|
249
318
|
return res.status(status).json({ error, spardingProof: proof });
|
|
250
319
|
}
|
|
251
320
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
321
|
+
if (proof.decision === 'require_human') {
|
|
322
|
+
// THE GATE THE PROOF ALWAYS LACKED.
|
|
323
|
+
// v0.5.0 had no branch here, so a require_human decision fell straight through to the
|
|
324
|
+
// host route below: the write happened, THEN the proof said "confirmation required".
|
|
325
|
+
// We stop here. The host route is not touched. We mint a single-use nonce and a preview
|
|
326
|
+
// the LLM reads in its tool-result, and only /invoke/confirm replays it.
|
|
327
|
+
spardaSweepPending();
|
|
328
|
+
const confirm = spardaNonce();
|
|
329
|
+
SPARDA_PENDING[confirm] = { tool, args, expiresAt: Date.now() + SPARDA_CONFIRM_TTL_MS, createdAt: new Date().toISOString() };
|
|
330
|
+
let siblingName = null;
|
|
331
|
+
for (const [n, t] of Object.entries(SPARDA_TOOLS)) { if (t.method === 'GET' && t.path === spec.path) { siblingName = n; break; } }
|
|
332
|
+
spardaEvent('confirm', tool, 202, `gated, awaiting confirmation (risk: ${proof.risk})`);
|
|
333
|
+
return res.status(202).json({
|
|
334
|
+
status: 'awaiting_confirmation',
|
|
335
|
+
confirm,
|
|
336
|
+
expiresInMs: SPARDA_CONFIRM_TTL_MS,
|
|
337
|
+
preview: {
|
|
338
|
+
tool,
|
|
339
|
+
method: spec.method,
|
|
340
|
+
path: spec.path,
|
|
341
|
+
willSendBody: spec.method !== 'GET' && args.body !== undefined,
|
|
342
|
+
reversibleHint: proof.checks.reversibleHint,
|
|
343
|
+
inspectFirst: siblingName ? { tool: siblingName, why: 'GET on the same path — read current state before committing this write' } : null,
|
|
344
|
+
},
|
|
345
|
+
// this string is what the LLM reads: the security decision IS the instruction.
|
|
346
|
+
instruction: `NOT EXECUTED. ${spec.method} ${spec.path} is gated by policy (${proof.reasons[0] || 'require_human'}); the host route was not touched.${siblingName ? ` First call "${siblingName}" to inspect current state, then` : ' To'} confirm via POST /invoke/confirm { "confirm": "${confirm}" }. Single-use, expires in ${Math.round(SPARDA_CONFIRM_TTL_MS / 1000)}s.`,
|
|
347
|
+
spardingProof: proof,
|
|
256
348
|
});
|
|
257
|
-
|
|
258
|
-
for (const [k, v] of Object.entries(args)) {
|
|
259
|
-
if (k === 'body' || spec.pathParams.includes(k) || v === undefined) continue;
|
|
260
|
-
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
261
|
-
}
|
|
262
|
-
if (query.length) url += `?${query.join('&')}`;
|
|
349
|
+
}
|
|
263
350
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
351
|
+
// proof.decision === 'allow' — exercise the host route
|
|
352
|
+
return spardaExecute(tool, spec, args, proof, t0, res);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
spardaRouter.post('/invoke/confirm', SPARDA_JSON, async (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
356
|
+
const t0 = Date.now();
|
|
357
|
+
const confirm = (req.body ?? {}).confirm;
|
|
358
|
+
if (typeof confirm !== 'string' || !confirm) {
|
|
359
|
+
return res.status(400).json({ error: 'missing confirm token (expected { confirm: "..." })' });
|
|
360
|
+
}
|
|
361
|
+
spardaSweepPending();
|
|
362
|
+
const pending = SPARDA_PENDING[confirm];
|
|
363
|
+
if (pending) delete SPARDA_PENDING[confirm]; // consume first — a token can never replay
|
|
364
|
+
if (!pending) {
|
|
365
|
+
return res.status(409).json({ error: 'unknown or expired confirmation token', hint: 're-issue the write via /invoke to mint a fresh token' });
|
|
366
|
+
}
|
|
367
|
+
if (Date.now() > pending.expiresAt) {
|
|
368
|
+
return res.status(409).json({ error: 'confirmation token expired', hint: 're-issue the write via /invoke' });
|
|
369
|
+
}
|
|
370
|
+
const spec = SPARDA_TOOLS[pending.tool];
|
|
371
|
+
// re-judge at commit time: the tool may have been quarantined or disabled since minting
|
|
372
|
+
const proof = spardaProof(pending.tool, spec, pending.args);
|
|
373
|
+
if (proof.decision === 'block') {
|
|
374
|
+
return res.status(403).json({ error: 'confirmation no longer valid', reason: proof.reasons[0] || 'blocked', spardingProof: proof });
|
|
284
375
|
}
|
|
376
|
+
spardaEvent('confirm', pending.tool, 200, 'confirmed -> executing');
|
|
377
|
+
return spardaExecute(pending.tool, spec, pending.args, proof, t0, res);
|
|
285
378
|
});
|
|
286
379
|
|
|
380
|
+
// verb safety: any non-POST on the invoke endpoints returns JSON 405 (was bare Express HTML — bug #5)
|
|
381
|
+
spardaRouter.all('/invoke', (req__REQ_TYPE__, res__RES_TYPE__) => res.status(405).json({ error: 'method not allowed', allow: 'POST' }));
|
|
382
|
+
spardaRouter.all('/invoke/confirm', (req__REQ_TYPE__, res__RES_TYPE__) => res.status(405).json({ error: 'method not allowed', allow: 'POST' }));
|
|
383
|
+
|
|
287
384
|
function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
|
|
288
385
|
const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
|
|
289
386
|
// innate immunity: latency far beyond the learned baseline is an antigen
|
|
@@ -304,3 +401,17 @@ function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
|
|
|
304
401
|
spardaEvent('immune', tool, 503, `quarantined after ${st.consecutive5xx} consecutive 5xx (cooldown ${SPARDA_QUARANTINE_MS}ms)`);
|
|
305
402
|
}
|
|
306
403
|
}
|
|
404
|
+
|
|
405
|
+
// anything unmatched under the router → JSON 404, never bare Express HTML
|
|
406
|
+
spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__) => res.status(404).json({ error: 'not found', path: req.path }));
|
|
407
|
+
|
|
408
|
+
// error envelope (must stay last). body-parser throws SyntaxError on malformed JSON, and
|
|
409
|
+
// anything thrown downstream used to fall through to Express' HTML error page — leaking the
|
|
410
|
+
// internal stack trace. Every response stays JSON; the stack stays server-side, correlated
|
|
411
|
+
// to /events by errorId.
|
|
412
|
+
spardaRouter.use((err__ANY_TYPE__, req__REQ_TYPE__, res__RES_TYPE__, _next__NEXT_TYPE__) => {
|
|
413
|
+
const errorId = 'err_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
414
|
+
spardaEvent('router', null, (err && err.status) || 500, `[${errorId}] ${err && err.message ? err.message : err}`);
|
|
415
|
+
const status = err && (err.type === 'entity.parse.failed' || err instanceof SyntaxError) ? 400 : (err && err.status) || 500;
|
|
416
|
+
res.status(status).json({ error: status === 400 ? 'invalid JSON body' : 'internal error', errorId });
|
|
417
|
+
});
|