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 CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.5.0",
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.2",
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', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE__) => {
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
- try {
253
- let url = spec.path.replace(/:(\w+)/g, (_, name) => {
254
- const v = args[name];
255
- return encodeURIComponent(String(v));
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
- const query = [];
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
- const init = { method: spec.method, headers: {} };
265
- if (spec.method !== 'GET' && args.body !== undefined) {
266
- init.headers['content-type'] = 'application/json';
267
- init.body = JSON.stringify(args.body);
268
- }
269
- SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
270
- const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
271
- const text = await upstream.text();
272
- let data; try { data = JSON.parse(text); } catch { data = text; }
273
- spardaRecord(tool, upstream.status, Date.now() - t0);
274
- if (spec.method === 'GET' && upstream.status === 200) {
275
- // canonical argsig: sorted entries, so the AI's argument order never splits a signature
276
- spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
277
- }
278
- if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
279
- return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
280
- } catch (err__ANY_TYPE__) {
281
- spardaRecord(tool, err.status ?? 502, Date.now() - t0);
282
- spardaEvent('invoke', tool, err.status ?? 502, err.message);
283
- return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
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
+ });