sparda-mcp 0.3.0 → 0.5.0

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.
@@ -0,0 +1,66 @@
1
+ // ui/style.js — zero-dep ANSI styling for the HUMAN commands (init, remove,
2
+ // doctor). Never import this from the bridge path: there, stdout is the MCP
3
+ // protocol (hard rule #2) and stderr must stay grep-able plain text.
4
+
5
+ // SPARDA identity: violet → cyan, same stops as the brand gradient
6
+ const VIOLET = [192, 132, 252]; // #c084fc
7
+ const CYAN = [103, 232, 249]; // #67e8f9
8
+ const RESET = '\x1b[0m';
9
+
10
+ // evaluated per call so tests (and runtime env changes) see the truth;
11
+ // honors https://no-color.org and the FORCE_COLOR convention
12
+ function enabled() {
13
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true;
14
+ if (process.env.NO_COLOR !== undefined) return false;
15
+ return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
16
+ }
17
+
18
+ function truecolor() {
19
+ return /truecolor|24bit/i.test(process.env.COLORTERM ?? '') ||
20
+ ['iTerm.app', 'vscode', 'WezTerm', 'ghostty'].includes(process.env.TERM_PROGRAM ?? '') ||
21
+ Boolean(process.env.WT_SESSION);
22
+ }
23
+
24
+ const sgr = (open) => (s) => (enabled() ? `\x1b[${open}m${s}${RESET}` : String(s));
25
+
26
+ export const c = {
27
+ dim: sgr('2'),
28
+ bold: sgr('1'),
29
+ green: sgr('32'),
30
+ yellow: sgr('33'),
31
+ red: sgr('31'),
32
+ cyan: sgr('38;5;117'),
33
+ violet: sgr('38;5;177'),
34
+ };
35
+
36
+ // the brand banner: per-character violet→cyan interpolation in truecolor,
37
+ // a stepped 256-color ramp elsewhere, plain text when colors are off
38
+ export function gradient(text) {
39
+ if (!enabled()) return text;
40
+ const chars = [...text];
41
+ if (truecolor()) {
42
+ const body = chars.map((ch, i) => {
43
+ const t = chars.length === 1 ? 0 : i / (chars.length - 1);
44
+ const [r, g, b] = VIOLET.map((v, k) => Math.round(v + (CYAN[k] - v) * t));
45
+ return `\x1b[1m\x1b[38;2;${r};${g};${b}m${ch}`;
46
+ }).join('');
47
+ return body + RESET;
48
+ }
49
+ const ramp = [177, 141, 147, 153, 117];
50
+ const body = chars.map((ch, i) => {
51
+ const step = ramp[Math.min(ramp.length - 1, Math.floor((i / chars.length) * ramp.length))];
52
+ return `\x1b[1m\x1b[38;5;${step}m${ch}`;
53
+ }).join('');
54
+ return body + RESET;
55
+ }
56
+
57
+ // single-pass JSON highlighter (keys violet, strings cyan, punctuation dim) —
58
+ // one pass on purpose: a second regex pass would chew the ANSI escapes of the first
59
+ export function colorizeJson(json) {
60
+ if (!enabled()) return json;
61
+ return json.replace(/("(?:[^"\\]|\\.)*")(\s*:)?|([{}[\],])/g, (m, str, colon, punct) => {
62
+ if (punct) return c.dim(punct);
63
+ if (colon !== undefined) return c.violet(str) + c.dim(colon);
64
+ return c.cyan(str);
65
+ });
66
+ }
@@ -5,6 +5,7 @@
5
5
  __IMPORT_LINE__
6
6
 
7
7
  const SPARDA_TOOLS = __TOOLS_JSON__;
8
+ const SPARDA_POLICIES = __SPARDING_POLICIES__;
8
9
 
9
10
  const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
10
11
  const SPARDA_PORT = __PORT__;
@@ -15,6 +16,45 @@ let SPARDA_SEQ = 0;
15
16
  // immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
16
17
  const SPARDA_QUARANTINE__STATS_TYPE__ = {};
17
18
  const SPARDA_QUARANTINE_MS = Number(process.env.SPARDA_QUARANTINE_MS ?? 60000);
19
+ // recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
20
+ // Day 1 it reads 0% — the circle fills with usage (a measure, never a promise).
21
+ const SPARDA_RECYCLE__STATS_TYPE__ = { servedByCircle: 0, paidFull: 0 };
22
+ function spardaRecycleRate() {
23
+ const total = SPARDA_RECYCLE.servedByCircle + SPARDA_RECYCLE.paidFull;
24
+ return total ? Math.round((SPARDA_RECYCLE.servedByCircle * 100) / total) : 0;
25
+ }
26
+ // thermodynamic route classification: a GET whose repeated identical args keep
27
+ // returning identical bodies is observed-pure (its result pre-exists — recyclable);
28
+ // writes erase by definition. Observation only, never a guess.
29
+ const SPARDA_PURITY__STATS_TYPE__ = {};
30
+ function spardaHash(s__ANY_TYPE__) {
31
+ let h = 0x811c9dc5; // FNV-1a, capped: a fingerprint, not a checksum of megabytes
32
+ for (let i = 0; i < s.length && i < 65536; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
33
+ return h;
34
+ }
35
+ function spardaObservePurity(tool__ANY_TYPE__, argsig__ANY_TYPE__, body__ANY_TYPE__) {
36
+ const pu = SPARDA_PURITY[tool] ?? (SPARDA_PURITY[tool] = { sigs: {}, repeats: 0, mismatches: 0 });
37
+ const h = spardaHash(body);
38
+ const known = pu.sigs[argsig];
39
+ if (known === undefined) {
40
+ if (Object.keys(pu.sigs).length >= 20) return; // bounded: enough sigs to judge
41
+ pu.sigs[argsig] = h;
42
+ } else if (known === h) pu.repeats += 1;
43
+ else { pu.mismatches += 1; pu.sigs[argsig] = h; } // the latest real answer is the truth
44
+ }
45
+ function spardaPuritySnapshot() {
46
+ const out__STATS_TYPE__ = {};
47
+ for (const [name, spec] of Object.entries(SPARDA_TOOLS)) {
48
+ if (spec.method !== 'GET') { out[name] = { class: 'erasing', repeats: 0, mismatches: 0 }; continue; }
49
+ const pu = SPARDA_PURITY[name];
50
+ out[name] = {
51
+ class: !pu ? 'unknown' : pu.mismatches > 0 ? 'volatile' : pu.repeats >= 3 ? 'pure' : 'unknown',
52
+ repeats: pu ? pu.repeats : 0,
53
+ mismatches: pu ? pu.mismatches : 0,
54
+ };
55
+ }
56
+ return out;
57
+ }
18
58
  function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, message__ANY_TYPE__) {
19
59
  SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
20
60
  if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
@@ -22,6 +62,134 @@ function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, m
22
62
  // observe-only: uncaughtExceptionMonitor never changes the host app's crash behavior
23
63
  process.on('uncaughtExceptionMonitor', (err__ANY_TYPE__) => spardaEvent('process', null, null, err && err.message ? err.message : err));
24
64
 
65
+ function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
66
+ const checks = {
67
+ knownTool: spec !== undefined,
68
+ enabled: spec ? spec.enabled : false,
69
+ loopSafe: spec ? !spec.path.startsWith('/mcp') : false,
70
+ methodClass: spec ? (spec.method === 'GET' ? 'read' : 'write') : 'read',
71
+ pathParamsPresent: true,
72
+ hasBodyForWrite: true,
73
+ reversibleHint: false,
74
+ quarantineSafe: true,
75
+ };
76
+
77
+ const reasons = [];
78
+
79
+ if (!checks.knownTool) {
80
+ reasons.push('unknown tool');
81
+ return {
82
+ version: 'sparding-proof/v0.1',
83
+ risk: 'blocked',
84
+ decision: 'block',
85
+ reasons,
86
+ checks,
87
+ };
88
+ }
89
+
90
+ if (!checks.enabled) {
91
+ reasons.push('tool disabled (write-safety)');
92
+ }
93
+ if (!checks.loopSafe) {
94
+ reasons.push('self-referential tool blocked (loop protection)');
95
+ }
96
+
97
+ const quarantined = SPARDA_QUARANTINE[tool];
98
+ if (quarantined) {
99
+ if (Date.now() < quarantined.until) {
100
+ checks.quarantineSafe = false;
101
+ reasons.push(`tool quarantined (immune system): ${quarantined.reason}`);
102
+ } else {
103
+ delete SPARDA_QUARANTINE[tool];
104
+ if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
105
+ }
106
+ }
107
+
108
+ for (const name of spec.pathParams ?? []) {
109
+ if (args[name] === undefined) {
110
+ checks.pathParamsPresent = false;
111
+ reasons.push(`missing path param: ${name}`);
112
+ }
113
+ }
114
+
115
+ if (checks.methodClass === 'write') {
116
+ if (args.body === undefined) {
117
+ checks.hasBodyForWrite = false;
118
+ }
119
+ }
120
+
121
+ if (spec.method === 'GET') {
122
+ checks.reversibleHint = true;
123
+ } else {
124
+ checks.reversibleHint = Object.values(SPARDA_TOOLS).some((t__ANY_TYPE__) => t.method === 'GET' && t.path === spec.path);
125
+ }
126
+
127
+ let risk = 'low';
128
+ let decision = 'allow';
129
+
130
+ if (!checks.enabled || !checks.loopSafe || !checks.quarantineSafe || !checks.pathParamsPresent) {
131
+ decision = 'block';
132
+ risk = 'blocked';
133
+ return {
134
+ version: 'sparding-proof/v0.1',
135
+ risk,
136
+ decision,
137
+ reasons,
138
+ checks,
139
+ };
140
+ }
141
+
142
+ const policies = SPARDA_POLICIES ?? {};
143
+ if (checks.methodClass === 'read') {
144
+ const readPolicy = policies.reads ?? 'allow';
145
+ if (readPolicy === 'block') {
146
+ decision = 'block';
147
+ risk = 'blocked';
148
+ reasons.push('read policy blocks execution');
149
+ } else if (readPolicy === 'require_human') {
150
+ decision = 'require_human';
151
+ risk = 'medium';
152
+ reasons.push('read policy requires human confirmation');
153
+ }
154
+ } else {
155
+ const isDelete = spec.method === 'DELETE';
156
+ const deletePolicy = policies.deletes ?? 'block';
157
+ const writePolicy = policies.writes ?? 'require_human';
158
+
159
+ if (isDelete && deletePolicy === 'block') {
160
+ decision = 'block';
161
+ risk = 'blocked';
162
+ reasons.push('delete policy blocks execution');
163
+ } else if (isDelete && deletePolicy === 'require_human') {
164
+ decision = 'require_human';
165
+ risk = 'high';
166
+ reasons.push('delete operation requires human confirmation');
167
+ } else if (isDelete && deletePolicy === 'allow') {
168
+ decision = 'allow';
169
+ risk = 'low';
170
+ } else if (writePolicy === 'block') {
171
+ decision = 'block';
172
+ risk = 'blocked';
173
+ reasons.push('write policy blocks execution');
174
+ } else if (writePolicy === 'require_human') {
175
+ decision = 'require_human';
176
+ risk = 'medium';
177
+ reasons.push('write operation requires human confirmation');
178
+ } else {
179
+ decision = 'allow';
180
+ risk = 'low';
181
+ }
182
+ }
183
+
184
+ return {
185
+ version: 'sparding-proof/v0.1',
186
+ risk,
187
+ decision,
188
+ reasons,
189
+ checks,
190
+ };
191
+ }
192
+
25
193
  __ROUTER_DECL__
26
194
 
27
195
  spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
@@ -33,7 +201,7 @@ spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
33
201
 
34
202
  spardaRouter.get('/tools', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json(SPARDA_TOOLS));
35
203
 
36
- spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE }));
204
+ spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE, recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() }, purity: spardaPuritySnapshot() }));
37
205
 
38
206
  spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
39
207
  const since = Number(req.query.since ?? 0) || 0;
@@ -44,26 +212,46 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
44
212
  const { tool, args = {} } = req.body ?? {};
45
213
  const t0 = Date.now();
46
214
  const spec = SPARDA_TOOLS[tool];
47
- if (!spec) return res.status(404).json({ error: `unknown tool: ${tool}` });
48
- if (!spec.enabled) {
49
- return res.status(403).json({ error: `tool disabled (write-safety): ${tool}`, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init' });
50
- }
51
- if (spec.path.startsWith('/mcp')) {
52
- return res.status(400).json({ error: 'self-referential tool blocked (loop protection)' });
53
- }
54
- const quarantined = SPARDA_QUARANTINE[tool];
55
- if (quarantined) {
56
- if (Date.now() < quarantined.until) {
57
- return res.status(503).json({ error: `tool quarantined (immune system): ${tool}`, reason: quarantined.reason, retryInMs: quarantined.until - Date.now() });
215
+
216
+ const proof = spardaProof(tool, spec, args);
217
+
218
+ if (proof.decision === 'block') {
219
+ let status = 400;
220
+ let error = 'bad request';
221
+ if (!proof.checks.knownTool) {
222
+ status = 404;
223
+ error = `unknown tool: ${tool}`;
224
+ } else if (!proof.checks.enabled) {
225
+ status = 403;
226
+ error = `tool disabled (write-safety): ${tool}`;
227
+ return res.status(status).json({ error, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init', spardingProof: proof });
228
+ } else if (!proof.checks.loopSafe) {
229
+ status = 400;
230
+ error = 'self-referential tool blocked (loop protection)';
231
+ } else if (!proof.checks.quarantineSafe) {
232
+ status = 503;
233
+ const quarantined = SPARDA_QUARANTINE[tool];
234
+ error = `tool quarantined (immune system): ${tool}`;
235
+ SPARDA_RECYCLE.servedByCircle += 1;
236
+ return res.status(status).json({
237
+ error,
238
+ reason: quarantined ? quarantined.reason : '',
239
+ retryInMs: quarantined ? Math.max(0, quarantined.until - Date.now()) : 0,
240
+ spardingProof: proof
241
+ });
242
+ } else {
243
+ status = 400;
244
+ error = proof.reasons[0] || 'blocked';
245
+ if (proof.reasons.some((r__ANY_TYPE__) => r.includes('policy blocks execution'))) {
246
+ status = 403;
247
+ }
58
248
  }
59
- // half-open: cooldown elapsed allow one probe; a single new 5xx re-quarantines
60
- delete SPARDA_QUARANTINE[tool];
61
- if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
249
+ return res.status(status).json({ error, spardingProof: proof });
62
250
  }
251
+
63
252
  try {
64
253
  let url = spec.path.replace(/:(\w+)/g, (_, name) => {
65
254
  const v = args[name];
66
- if (v === undefined) throw Object.assign(new Error(`missing path param: ${name}`), { status: 400 });
67
255
  return encodeURIComponent(String(v));
68
256
  });
69
257
  const query = [];
@@ -78,28 +266,35 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
78
266
  init.headers['content-type'] = 'application/json';
79
267
  init.body = JSON.stringify(args.body);
80
268
  }
269
+ SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
81
270
  const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
82
271
  const text = await upstream.text();
83
272
  let data; try { data = JSON.parse(text); } catch { data = text; }
84
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
+ }
85
278
  if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
86
- return res.status(200).json({ upstreamStatus: upstream.status, data });
279
+ return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
87
280
  } catch (err__ANY_TYPE__) {
88
281
  spardaRecord(tool, err.status ?? 502, Date.now() - t0);
89
282
  spardaEvent('invoke', tool, err.status ?? 502, err.message);
90
- return res.status(err.status ?? 502).json({ error: err.message });
283
+ return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
91
284
  }
92
285
  });
93
286
 
94
287
  function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
95
- const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
288
+ const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
96
289
  // innate immunity: latency far beyond the learned baseline is an antigen
97
290
  if (st.calls >= 5 && ms > Math.max((st.totalMs / st.calls) * 10, 200)) {
98
291
  spardaEvent('immune', tool, status, `latency anomaly: ${ms}ms vs ~${Math.round(st.totalMs / st.calls)}ms baseline`);
99
292
  }
100
293
  st.calls += 1;
101
294
  st.totalMs += ms;
102
- if (status >= 400) st.errors += 1;
295
+ // 5xx = server failure (feeds the immune system); 4xx = a valid client answer (404 etc), tracked apart so the count doesn't lie
296
+ if (status >= 500) st.errors += 1;
297
+ else if (status >= 400) st.clientErrors += 1;
103
298
  st.lastStatus = status;
104
299
  st.lastTs = new Date().toISOString();
105
300
  if (status >= 500) st.consecutive5xx += 1;