sparda-mcp 0.1.0 → 0.3.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.
@@ -4,7 +4,13 @@ import fs from 'node:fs';
4
4
  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
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
+ import {
8
+ CallToolRequestSchema, ListToolsRequestSchema,
9
+ ListPromptsRequestSchema, GetPromptRequestSchema,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+ import { sanitizeDescription } from '../security/sanitize.js';
12
+
13
+ const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
8
14
 
9
15
  export async function startStdioBridge({ cwd, portOverride }) {
10
16
  // pitfall #1: neutralize any stray console.log from deps
@@ -16,30 +22,40 @@ export async function startStdioBridge({ cwd, portOverride }) {
16
22
  }
17
23
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
18
24
  const port = Number(portOverride ?? manifest.port);
25
+ const framework = manifest.framework;
19
26
  const key = manifest.localKey;
20
- const base = await waitForHost(port, key);
27
+ if (!key) {
28
+ throw Object.assign(new Error('localKey missing from sparda.json.'), { code: 'USER', hint: 'Re-run `npx sparda-mcp init` to regenerate it.' });
29
+ }
30
+ const base = await waitForHost(port, key, framework);
21
31
 
22
32
  // full tool specs live in the generated router; fetch them (single source of truth)
23
33
  const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers: { 'x-sparda-key': key } })).json();
24
34
 
25
- const enabled = Object.entries(toolSpecs).filter(([, t]) => t.enabled);
26
- const disabled = Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
35
+ const enabled = () => Object.entries(toolSpecs).filter(([, t]) => t.enabled);
36
+ const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
27
37
 
28
38
  const server = new Server(
29
- { name: `sparda-${path.basename(cwd)}`, version: '0.1.0' },
30
- { capabilities: { tools: {} } },
39
+ { name: `sparda-${path.basename(cwd)}`, version: '0.3.0' },
40
+ { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, logging: {} } },
31
41
  );
32
42
 
43
+ const descFor = (name, t) => {
44
+ const semantic = manifest.semantic?.descriptions?.[name];
45
+ const text = semantic || t.description || `${t.method} ${t.path}`;
46
+ return `${t.confidence === 'low' ? '[partial schema] ' : ''}${text}`;
47
+ };
48
+
33
49
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
34
50
  tools: [
35
- ...enabled.map(([name, t]) => ({
51
+ ...enabled().map(([name, t]) => ({
36
52
  name,
37
- description: `${t.confidence === 'low' ? '[partial schema] ' : ''}${t.description || `${t.method} ${t.path}`}`,
53
+ description: descFor(name, t),
38
54
  inputSchema: schemaFor(t),
39
55
  })),
40
56
  {
41
57
  name: 'sparda_info',
42
- description: 'Info about this MCP server. Generated by SPARDA (npx sparda-mcp init) — turn any codebase into an MCP server in 3 minutes. github.com/zyx77550/sparda',
58
+ description: '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',
43
59
  inputSchema: { type: 'object', properties: {} },
44
60
  },
45
61
  {
@@ -47,44 +63,267 @@ export async function startStdioBridge({ cwd, portOverride }) {
47
63
  description: 'Lists write tools (POST/PUT/DELETE) disabled by SPARDA write-safety, and how to enable them.',
48
64
  inputSchema: { type: 'object', properties: {} },
49
65
  },
66
+ {
67
+ name: 'sparda_get_context',
68
+ description: '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.',
69
+ inputSchema: { type: 'object', properties: {} },
70
+ },
50
71
  ],
51
72
  }));
52
73
 
74
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
75
+ prompts: (manifest.semantic?.workflows ?? []).map((w) => ({
76
+ name: w.name,
77
+ description: w.description,
78
+ })),
79
+ }));
80
+
81
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
82
+ const w = (manifest.semantic?.workflows ?? []).find((x) => x.name === req.params.name);
83
+ if (!w) throw new Error(`unknown prompt: ${req.params.name}`);
84
+ return {
85
+ description: w.description,
86
+ messages: [{
87
+ role: 'user',
88
+ content: {
89
+ type: 'text',
90
+ 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')}`,
91
+ },
92
+ }],
93
+ };
94
+ });
95
+
53
96
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
54
97
  const { name, arguments: args = {} } = req.params;
55
98
 
56
99
  if (name === 'sparda_info') {
57
100
  return text(JSON.stringify({
58
101
  project: path.basename(cwd), framework: manifest.framework,
59
- tools_enabled: enabled.length, tools_disabled_write_safety: disabled.length,
60
- generated_by: 'SPARDA npx sparda-mcp init — github.com/zyx77550/sparda',
102
+ tools_enabled: enabled().length, tools_disabled_write_safety: disabled().length,
103
+ workflows: (manifest.semantic?.workflows ?? []).length,
104
+ immune_antibodies: Object.keys(manifest.immune?.antibodies ?? {}).length,
105
+ generated_by: 'SPARDA by Residual Labs (residual-labs.fr) — npx sparda-mcp init — github.com/zyx77550/sparda',
106
+ }, null, 2));
107
+ }
108
+ if (name === 'sparda_get_context') {
109
+ const headers = { 'x-sparda-key': key };
110
+ const [stats, events] = await Promise.all([
111
+ fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
112
+ fetch(`${base}/mcp/events?since=0`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
113
+ ]);
114
+ return text(JSON.stringify({
115
+ project: path.basename(cwd),
116
+ framework: manifest.framework,
117
+ port,
118
+ tools: Object.fromEntries(Object.entries(toolSpecs).map(([n, t]) => [n, { method: t.method, path: t.path, enabled: t.enabled, description: descFor(n, t) }])),
119
+ workflows: manifest.semantic?.workflows ?? [],
120
+ runtime: stats,
121
+ recentEvents: (events?.events ?? []).slice(-20),
122
+ immuneMemory: manifest.immune?.antibodies ?? {},
123
+ 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.',
61
124
  }, null, 2));
62
125
  }
63
126
  if (name === 'sparda_list_disabled_tools') {
64
- return text(disabled.length
65
- ? `Disabled (write-safety):\n${disabled.map(([n, t]) => `- ${n} (${t.method} ${t.path})`).join('\n')}\n\nTo enable: set "enabled": true in sparda.json, then re-run \`npx sparda-mcp init\` and restart this bridge.`
127
+ return text(disabled().length
128
+ ? `Disabled (write-safety):\n${disabled().map(([n, t]) => `- ${n} (${t.method} ${t.path})`).join('\n')}\n\nTo enable: set "enabled": true in sparda.json, then re-run \`npx sparda-mcp init\` and restart this bridge.`
66
129
  : 'No disabled tools.');
67
130
  }
68
131
 
69
- try {
70
- const res = await fetch(`${base}/mcp/invoke`, {
71
- method: 'POST',
72
- headers: { 'content-type': 'application/json', 'x-sparda-key': key },
73
- body: JSON.stringify({ tool: name, args }),
74
- signal: AbortSignal.timeout(30_000),
75
- });
76
- const payload = await res.json().catch(() => ({ error: 'non-JSON response from host' }));
77
- const pretty = JSON.stringify(payload, null, 2);
78
- const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated ${pretty.length} chars total]` : pretty;
79
- return { content: [{ type: 'text', text: truncated }], isError: res.status >= 400 };
80
- } catch (err) {
81
- return { content: [{ type: 'text', text: `Host app error: ${err.message}. Check that your server is still running on :${port}.` }], isError: true };
132
+ const spec = toolSpecs[name];
133
+ const isWrite = spec && spec.method !== 'GET';
134
+
135
+ // human-in-the-loop: confirm writes natively in the client UI when supported
136
+ if (isWrite && server.getClientCapabilities()?.elicitation) {
137
+ const answer = await server.elicitInput({
138
+ message: `SPARDA: allow ${spec.method} ${spec.path}? This is a write operation on your live app.`,
139
+ requestedSchema: {
140
+ type: 'object',
141
+ properties: { confirm: { type: 'boolean', title: `Allow ${spec.method} ${spec.path}` } },
142
+ required: ['confirm'],
143
+ },
144
+ }).catch(() => null);
145
+ if (!answer || answer.action !== 'accept' || answer.content?.confirm !== true) {
146
+ return { content: [{ type: 'text', text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.` }], isError: true };
147
+ }
82
148
  }
149
+
150
+ const payload = await invoke(base, key, name, args);
151
+ if (payload === null) {
152
+ return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
153
+ }
154
+
155
+ // proof-after-write: re-read the same path so the AI sees the effect of its write
156
+ let proof = null;
157
+ if (isWrite && payload.upstreamStatus < 400) {
158
+ const getter = Object.entries(toolSpecs).find(([, t]) => t.enabled && t.method === 'GET' && t.path === spec.path);
159
+ if (getter) {
160
+ const after = await invoke(base, key, getter[0], pickPathArgs(spec, args));
161
+ if (after && after.upstreamStatus < 400) proof = { readBack: getter[0], state: after.data };
162
+ }
163
+ }
164
+
165
+ const body = proof ? { ...payload, proof } : payload;
166
+ const pretty = JSON.stringify(body, null, 2);
167
+ const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty;
168
+ // router-level rejections (quarantine, disabled tool, bad params) carry `error` and no upstreamStatus
169
+ const isError = payload.upstreamStatus !== undefined ? payload.upstreamStatus >= 400 : Boolean(payload.error);
170
+ return { content: [{ type: 'text', text: truncated }], isError };
83
171
  });
84
172
 
173
+ let pollTimer = null;
174
+ server.oninitialized = () => {
175
+ pollTimer = startEventPolling(server, base, key, manifest, manifestPath);
176
+ runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) => console.error(`[sparda] semantic pass skipped: ${e.message}`));
177
+ };
178
+ server.onclose = () => { if (pollTimer) clearInterval(pollTimer); };
179
+
85
180
  const transport = new StdioServerTransport();
86
181
  await server.connect(transport);
87
- console.error(`[sparda] MCP bridge running. ${enabled.length} tools enabled, ${disabled.length} disabled (write-safety). Host: ${base}`);
182
+ console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety). Host: ${base}`);
183
+ }
184
+
185
+ async function invoke(base, key, tool, args) {
186
+ try {
187
+ const res = await fetch(`${base}/mcp/invoke`, {
188
+ method: 'POST',
189
+ headers: { 'content-type': 'application/json', 'x-sparda-key': key },
190
+ body: JSON.stringify({ tool, args }),
191
+ signal: AbortSignal.timeout(30_000),
192
+ });
193
+ return await res.json().catch(() => ({ upstreamStatus: res.status, error: 'non-JSON response from host' }));
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // keep only the path params of the original call so the read-back targets the same resource
200
+ function pickPathArgs(spec, args) {
201
+ const out = {};
202
+ for (const p of spec.pathParams ?? []) if (args[p] !== undefined) out[p] = args[p];
203
+ return out;
204
+ }
205
+
206
+ // live error feed: host app errors reach the AI as MCP logging notifications.
207
+ // immune memory: known failure signatures carry their cached diagnosis (zero tokens);
208
+ // new signatures wake the client's LLM once, and the antibody is stored in sparda.json.
209
+ function startEventPolling(server, base, key, manifest, manifestPath) {
210
+ let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
211
+ const timer = setInterval(async () => {
212
+ try {
213
+ const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, { headers: { 'x-sparda-key': key }, signal: AbortSignal.timeout(2000) });
214
+ if (!r.ok) return;
215
+ const { seq, events } = await r.json();
216
+ if (lastSeq === null) { lastSeq = seq; return; }
217
+ lastSeq = Math.max(lastSeq, seq);
218
+ for (const ev of events) {
219
+ const sig = `${ev.source}|${ev.tool ?? ''}|${ev.status ?? ''}`;
220
+ const antibody = manifest.immune?.antibodies?.[sig];
221
+ if (antibody) {
222
+ antibody.hits = (antibody.hits ?? 0) + 1;
223
+ antibody.lastSeen = ev.ts;
224
+ persistImmune(manifestPath, manifest);
225
+ await server.sendLoggingMessage({ level: 'error', logger: 'sparda', data: { ...ev, diagnosis: antibody.diagnosis } }).catch(() => {});
226
+ } else {
227
+ await server.sendLoggingMessage({ level: 'error', logger: 'sparda', data: ev }).catch(() => {});
228
+ diagnoseAndRemember({ server, manifest, manifestPath, ev, sig }).catch(() => {});
229
+ }
230
+ }
231
+ } catch { /* host briefly unreachable — next tick */ }
232
+ }, EVENT_POLL_MS);
233
+ timer.unref?.();
234
+ return timer;
235
+ }
236
+
237
+ // adaptive immunity: costly intelligence is summoned only when the body has a fever,
238
+ // and the diagnosis is remembered so the same fever never costs tokens again.
239
+ const diagnosing = new Set();
240
+ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig }) {
241
+ if (!server.getClientCapabilities()?.sampling) return;
242
+ if (diagnosing.has(sig)) return;
243
+ manifest.immune ??= {};
244
+ manifest.immune.antibodies ??= {};
245
+ const antibodies = manifest.immune.antibodies;
246
+ if (antibodies[sig] || Object.keys(antibodies).length >= 50) return; // bounded memory
247
+ diagnosing.add(sig);
248
+ try {
249
+ const res = await server.createMessage({
250
+ messages: [{
251
+ role: 'user',
252
+ content: {
253
+ type: 'text',
254
+ 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.`,
255
+ },
256
+ }],
257
+ maxTokens: 120,
258
+ });
259
+ const raw = res?.content?.type === 'text' ? res.content.text.trim() : '';
260
+ const { text: clean, flagged } = sanitizeDescription(raw, '');
261
+ if (!clean || flagged) return;
262
+ antibodies[sig] = { diagnosis: clean, firstSeen: ev.ts, lastSeen: ev.ts, hits: 1 };
263
+ persistImmune(manifestPath, manifest);
264
+ await server.sendLoggingMessage({
265
+ level: 'info', logger: 'sparda',
266
+ data: { source: 'immune', signature: sig, diagnosis: clean, note: 'antibody stored in sparda.json — the same failure will be diagnosed instantly, zero tokens' },
267
+ }).catch(() => {});
268
+ console.error(`[sparda] immune: antibody stored for ${sig}`);
269
+ } finally {
270
+ diagnosing.delete(sig);
271
+ }
272
+ }
273
+
274
+ function persistImmune(manifestPath, manifest) {
275
+ try {
276
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
277
+ onDisk.immune = manifest.immune;
278
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
279
+ } catch { /* disk briefly unavailable — the antibody stays in memory */ }
280
+ }
281
+
282
+ // semantic pass: uses the CLIENT's own model via MCP sampling — zero key, zero cost.
283
+ // Runs once, result cached in sparda.json (and preserved across re-init).
284
+ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }) {
285
+ if (manifest.semantic || !server.getClientCapabilities()?.sampling) return;
286
+
287
+ const inventory = Object.entries(toolSpecs).map(([n, t]) =>
288
+ `- ${n}: ${t.method} ${t.path}${t.description ? ` — ${t.description}` : ''}`).join('\n');
289
+ const res = await server.createMessage({
290
+ messages: [{
291
+ role: 'user',
292
+ content: {
293
+ type: 'text',
294
+ 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.`,
295
+ },
296
+ }],
297
+ maxTokens: 1500,
298
+ });
299
+
300
+ const raw = res?.content?.type === 'text' ? res.content.text : '';
301
+ const parsed = JSON.parse(raw.replace(/^```(json)?\s*/i, '').replace(/```\s*$/, ''));
302
+
303
+ const descriptions = {};
304
+ for (const [n, d] of Object.entries(parsed.descriptions ?? {})) {
305
+ if (!toolSpecs[n] || typeof d !== 'string') continue;
306
+ const { text: clean, flagged } = sanitizeDescription(d, '');
307
+ if (clean && !flagged) descriptions[n] = clean;
308
+ }
309
+ const workflows = (Array.isArray(parsed.workflows) ? parsed.workflows : [])
310
+ .filter((w) => w && typeof w.name === 'string' && typeof w.description === 'string' && Array.isArray(w.steps))
311
+ .slice(0, 5)
312
+ .map((w) => ({
313
+ name: w.name.toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 60),
314
+ description: sanitizeDescription(w.description, 'workflow').text,
315
+ steps: w.steps.filter((s) => typeof s === 'string' && toolSpecs[s]),
316
+ }))
317
+ .filter((w) => w.steps.length > 0);
318
+
319
+ manifest.semantic = { enrichedAt: new Date().toISOString(), source: 'mcp-sampling', descriptions, workflows };
320
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
321
+ onDisk.semantic = manifest.semantic;
322
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
323
+
324
+ await server.sendToolListChanged().catch(() => {});
325
+ await server.sendPromptListChanged().catch(() => {});
326
+ console.error(`[sparda] semantic pass done: ${Object.keys(descriptions).length} descriptions, ${workflows.length} workflows (cached in sparda.json)`);
88
327
  }
89
328
 
90
329
  function schemaFor(t) {
@@ -98,7 +337,7 @@ function schemaFor(t) {
98
337
 
99
338
  function text(t) { return { content: [{ type: 'text', text: t }] }; }
100
339
 
101
- async function waitForHost(port, key) {
340
+ async function waitForHost(port, key, framework) {
102
341
  const hosts = ['127.0.0.1', 'localhost'];
103
342
  for (let attempt = 0; attempt < 40; attempt++) {
104
343
  for (const h of hosts) {
@@ -108,7 +347,10 @@ async function waitForHost(port, key) {
108
347
  if (r.status === 401) throw Object.assign(new Error('Host app reachable but key mismatch — re-run `npx sparda-mcp init`.'), { code: 'USER' });
109
348
  } catch (e) { if (e.code === 'USER') throw e; }
110
349
  }
111
- if (attempt === 0) console.error(`[sparda] Waiting for host app on :${port} ... (start it with npm run dev — Ctrl+C to abort)`);
350
+ if (attempt === 0) {
351
+ const startCmd = framework === 'express' ? 'npm run dev' : 'fastapi dev';
352
+ console.error(`[sparda] Waiting for host app on :${port} ... (start it with ${startCmd} — Ctrl+C to abort)`);
353
+ }
112
354
  await new Promise((r) => setTimeout(r, 3000));
113
355
  }
114
356
  throw Object.assign(new Error(`Host app unreachable on :${port} after 2 minutes.`), { code: 'USER', hint: 'Start your server first, then run `npx sparda-mcp dev`.' });
@@ -9,19 +9,40 @@ const SPARDA_TOOLS = __TOOLS_JSON__;
9
9
  const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
10
10
  const SPARDA_PORT = __PORT__;
11
11
 
12
+ const SPARDA_STATS__STATS_TYPE__ = {};
13
+ const SPARDA_EVENTS__EVENTS_TYPE__ = [];
14
+ let SPARDA_SEQ = 0;
15
+ // immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
16
+ const SPARDA_QUARANTINE__STATS_TYPE__ = {};
17
+ const SPARDA_QUARANTINE_MS = Number(process.env.SPARDA_QUARANTINE_MS ?? 60000);
18
+ function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, message__ANY_TYPE__) {
19
+ SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
20
+ if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
21
+ }
22
+ // observe-only: uncaughtExceptionMonitor never changes the host app's crash behavior
23
+ process.on('uncaughtExceptionMonitor', (err__ANY_TYPE__) => spardaEvent('process', null, null, err && err.message ? err.message : err));
24
+
12
25
  __ROUTER_DECL__
13
26
 
14
- spardaRouter.use((req, res, next) => {
27
+ spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
15
28
  if (req.headers['x-sparda-key'] !== SPARDA_LOCAL_KEY) {
16
29
  return res.status(401).json({ error: 'unauthorized' });
17
30
  }
18
31
  next();
19
32
  });
20
33
 
21
- spardaRouter.get('/tools', (_req, res) => res.json(SPARDA_TOOLS));
34
+ spardaRouter.get('/tools', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json(SPARDA_TOOLS));
35
+
36
+ spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE }));
22
37
 
23
- spardaRouter.post('/invoke', __JSON_MW__, async (req, res) => {
38
+ spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
39
+ const since = Number(req.query.since ?? 0) || 0;
40
+ res.json({ seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e__ANY_TYPE__) => e.seq > since) });
41
+ });
42
+
43
+ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE__) => {
24
44
  const { tool, args = {} } = req.body ?? {};
45
+ const t0 = Date.now();
25
46
  const spec = SPARDA_TOOLS[tool];
26
47
  if (!spec) return res.status(404).json({ error: `unknown tool: ${tool}` });
27
48
  if (!spec.enabled) {
@@ -30,6 +51,15 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req, res) => {
30
51
  if (spec.path.startsWith('/mcp')) {
31
52
  return res.status(400).json({ error: 'self-referential tool blocked (loop protection)' });
32
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() });
58
+ }
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;
62
+ }
33
63
  try {
34
64
  let url = spec.path.replace(/:(\w+)/g, (_, name) => {
35
65
  const v = args[name];
@@ -51,8 +81,31 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req, res) => {
51
81
  const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
52
82
  const text = await upstream.text();
53
83
  let data; try { data = JSON.parse(text); } catch { data = text; }
84
+ spardaRecord(tool, upstream.status, Date.now() - t0);
85
+ if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
54
86
  return res.status(200).json({ upstreamStatus: upstream.status, data });
55
- } catch (err) {
87
+ } catch (err__ANY_TYPE__) {
88
+ spardaRecord(tool, err.status ?? 502, Date.now() - t0);
89
+ spardaEvent('invoke', tool, err.status ?? 502, err.message);
56
90
  return res.status(err.status ?? 502).json({ error: err.message });
57
91
  }
58
92
  });
93
+
94
+ 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 });
96
+ // innate immunity: latency far beyond the learned baseline is an antigen
97
+ if (st.calls >= 5 && ms > Math.max((st.totalMs / st.calls) * 10, 200)) {
98
+ spardaEvent('immune', tool, status, `latency anomaly: ${ms}ms vs ~${Math.round(st.totalMs / st.calls)}ms baseline`);
99
+ }
100
+ st.calls += 1;
101
+ st.totalMs += ms;
102
+ if (status >= 400) st.errors += 1;
103
+ st.lastStatus = status;
104
+ st.lastTs = new Date().toISOString();
105
+ if (status >= 500) st.consecutive5xx += 1;
106
+ else if (status < 400) st.consecutive5xx = 0;
107
+ if (st.consecutive5xx >= 3 && !SPARDA_QUARANTINE[tool]) {
108
+ SPARDA_QUARANTINE[tool] = { since: new Date().toISOString(), until: Date.now() + SPARDA_QUARANTINE_MS, reason: `${st.consecutive5xx} consecutive 5xx` };
109
+ spardaEvent('immune', tool, 503, `quarantined after ${st.consecutive5xx} consecutive 5xx (cooldown ${SPARDA_QUARANTINE_MS}ms)`);
110
+ }
111
+ }
@@ -0,0 +1,198 @@
1
+ # ============================================================
2
+ # SPARDA ROUTER — Auto-generated. DO NOT EDIT.
3
+ # Regenerate: npx sparda-mcp init • Remove: npx sparda-mcp remove
4
+ # ============================================================
5
+ from fastapi import APIRouter, Request, Response, HTTPException
6
+ from fastapi.responses import JSONResponse
7
+ import json
8
+ import urllib.request
9
+ import urllib.parse
10
+ import concurrent.futures
11
+ import re
12
+ import asyncio
13
+ import time
14
+ import datetime
15
+ import os
16
+
17
+ # json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
18
+ SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
19
+ SPARDA_LOCAL_KEY = "__LOCAL_KEY__"
20
+ SPARDA_PORT = __PORT__
21
+
22
+ SPARDA_STATS = {}
23
+ SPARDA_EVENTS = []
24
+ SPARDA_SEQ = 0
25
+ SPARDA_START = time.time()
26
+ # immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
27
+ SPARDA_QUARANTINE = {}
28
+ SPARDA_QUARANTINE_MS = int(os.environ.get("SPARDA_QUARANTINE_MS", "60000"))
29
+
30
+ def sparda_event(source, tool, status, message):
31
+ global SPARDA_SEQ
32
+ SPARDA_SEQ += 1
33
+ SPARDA_EVENTS.append({
34
+ "seq": SPARDA_SEQ,
35
+ "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
36
+ "source": source, "tool": tool, "status": status,
37
+ "message": str(message or "")[:500],
38
+ })
39
+ if len(SPARDA_EVENTS) > 100:
40
+ SPARDA_EVENTS.pop(0)
41
+
42
+ def sparda_record(tool, status, ms):
43
+ st = SPARDA_STATS.setdefault(tool, {"calls": 0, "errors": 0, "totalMs": 0, "lastStatus": None, "lastTs": None, "consecutive5xx": 0})
44
+ # innate immunity: latency far beyond the learned baseline is an antigen
45
+ if st["calls"] >= 5 and ms > max((st["totalMs"] / st["calls"]) * 10, 200):
46
+ sparda_event("immune", tool, status, f"latency anomaly: {ms}ms vs ~{round(st['totalMs'] / st['calls'])}ms baseline")
47
+ st["calls"] += 1
48
+ st["totalMs"] += ms
49
+ if status >= 400:
50
+ st["errors"] += 1
51
+ st["lastStatus"] = status
52
+ st["lastTs"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
53
+ if status >= 500:
54
+ st["consecutive5xx"] = st.get("consecutive5xx", 0) + 1
55
+ elif status < 400:
56
+ st["consecutive5xx"] = 0
57
+ if st.get("consecutive5xx", 0) >= 3 and tool not in SPARDA_QUARANTINE:
58
+ SPARDA_QUARANTINE[tool] = {
59
+ "since": datetime.datetime.now(datetime.timezone.utc).isoformat(),
60
+ "until": time.time() * 1000 + SPARDA_QUARANTINE_MS,
61
+ "reason": f"{st['consecutive5xx']} consecutive 5xx",
62
+ }
63
+ sparda_event("immune", tool, 503, f"quarantined after {st['consecutive5xx']} consecutive 5xx (cooldown {SPARDA_QUARANTINE_MS}ms)")
64
+
65
+ sparda_router = APIRouter()
66
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
67
+
68
+ def sync_fetch(url, method, headers, body_bytes):
69
+ req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method)
70
+ try:
71
+ with urllib.request.urlopen(req, timeout=30) as response:
72
+ status = response.status
73
+ content_type = response.headers.get("content-type", "")
74
+ data = response.read()
75
+ return status, content_type, data
76
+ except urllib.error.HTTPError as e:
77
+ return e.code, e.headers.get("content-type", ""), e.read()
78
+ except Exception as e:
79
+ raise e
80
+
81
+ @sparda_router.get("/tools")
82
+ async def get_tools(request: Request):
83
+ if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
84
+ return JSONResponse(status_code=401, content={"error": "unauthorized"})
85
+ return SPARDA_TOOLS
86
+
87
+ @sparda_router.get("/stats")
88
+ async def get_stats(request: Request):
89
+ if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
90
+ return JSONResponse(status_code=401, content={"error": "unauthorized"})
91
+ return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE}
92
+
93
+ @sparda_router.get("/events")
94
+ async def get_events(request: Request):
95
+ if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
96
+ return JSONResponse(status_code=401, content={"error": "unauthorized"})
97
+ try:
98
+ since = int(request.query_params.get("since", "0"))
99
+ except ValueError:
100
+ since = 0
101
+ return {"seq": SPARDA_SEQ, "events": [e for e in SPARDA_EVENTS if e["seq"] > since]}
102
+
103
+ @sparda_router.post("/invoke")
104
+ async def invoke_tool(request: Request):
105
+ if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
106
+ return JSONResponse(status_code=401, content={"error": "unauthorized"})
107
+
108
+ try:
109
+ body = await request.json()
110
+ except Exception:
111
+ body = {}
112
+
113
+ tool = body.get("tool")
114
+ args = body.get("args", {})
115
+
116
+ spec = SPARDA_TOOLS.get(tool)
117
+ if not spec:
118
+ return JSONResponse(status_code=404, content={"error": f"unknown tool: {tool}"})
119
+
120
+ if not spec.get("enabled"):
121
+ return JSONResponse(status_code=403, content={
122
+ "error": f"tool disabled (write-safety): {tool}",
123
+ "hint": "Enable it in sparda.json, then re-run: npx sparda-mcp init"
124
+ })
125
+
126
+ path = spec.get("path")
127
+ if path.startswith("/mcp"):
128
+ return JSONResponse(status_code=400, content={"error": "self-referential tool blocked (loop protection)"})
129
+
130
+ quarantined = SPARDA_QUARANTINE.get(tool)
131
+ if quarantined:
132
+ now_ms = time.time() * 1000
133
+ if now_ms < quarantined["until"]:
134
+ return JSONResponse(status_code=503, content={
135
+ "error": f"tool quarantined (immune system): {tool}",
136
+ "reason": quarantined["reason"],
137
+ "retryInMs": round(quarantined["until"] - now_ms),
138
+ })
139
+ # half-open: cooldown elapsed — allow one probe; a single new 5xx re-quarantines
140
+ del SPARDA_QUARANTINE[tool]
141
+ if tool in SPARDA_STATS:
142
+ SPARDA_STATS[tool]["consecutive5xx"] = 2
143
+
144
+ t0 = time.time()
145
+ try:
146
+ path_params = spec.get("pathParams", [])
147
+
148
+ def repl(match):
149
+ name = match.group(1)
150
+ val = args.get(name)
151
+ if val is None:
152
+ raise HTTPException(status_code=400, detail=f"missing path param: {name}")
153
+ return urllib.parse.quote(str(val))
154
+
155
+ resolved_path = re.sub(r'\{(\w+)\}', repl, path)
156
+
157
+ query_params = []
158
+ for k, v in args.items():
159
+ if k == 'body' or k in path_params or v is None:
160
+ continue
161
+ query_params.append(f"{urllib.parse.quote(k)}={urllib.parse.quote(str(v))}")
162
+
163
+ if query_params:
164
+ resolved_path += "?" + "&".join(query_params)
165
+
166
+ url = f"http://127.0.0.1:{SPARDA_PORT}{resolved_path}"
167
+ method = spec.get("method", "GET")
168
+
169
+ headers = {}
170
+ body_bytes = None
171
+
172
+ if method != "GET" and args.get("body") is not None:
173
+ headers["content-type"] = "application/json"
174
+ body_bytes = json.dumps(args["body"]).encode("utf-8")
175
+
176
+ loop = asyncio.get_running_loop()
177
+ status, content_type, data_bytes = await loop.run_in_executor(
178
+ executor, sync_fetch, url, method, headers, body_bytes
179
+ )
180
+
181
+ text_data = data_bytes.decode("utf-8", errors="ignore")
182
+ try:
183
+ data = json.loads(text_data)
184
+ except Exception:
185
+ data = text_data
186
+
187
+ sparda_record(tool, status, round((time.time() - t0) * 1000))
188
+ if status >= 500:
189
+ sparda_event("invoke", tool, status, text_data[:200])
190
+ return {"upstreamStatus": status, "data": data}
191
+ except HTTPException as e:
192
+ sparda_record(tool, e.status_code, round((time.time() - t0) * 1000))
193
+ sparda_event("invoke", tool, e.status_code, e.detail)
194
+ return JSONResponse(status_code=e.status_code, content={"error": e.detail})
195
+ except Exception as e:
196
+ sparda_record(tool, 502, round((time.time() - t0) * 1000))
197
+ sparda_event("invoke", tool, 502, str(e))
198
+ return JSONResponse(status_code=502, content={"error": str(e)})