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.
- package/LICENSE +89 -0
- package/README.md +50 -7
- package/package.json +6 -4
- package/src/commands/doctor.js +45 -7
- package/src/commands/hook.js +29 -0
- package/src/commands/init.js +26 -7
- package/src/commands/remove.js +48 -3
- package/src/commands/sync.js +49 -0
- package/src/detect.js +98 -2
- package/src/generator/express.js +46 -8
- package/src/generator/fastapi.js +211 -0
- package/src/generator/manifest.js +23 -0
- package/src/index.js +22 -3
- package/src/parser/express.js +27 -7
- package/src/parser/fastapi.js +26 -0
- package/src/parser/fastapi_extract.py +443 -0
- package/src/server/stdio.js +271 -29
- package/templates/express-router.txt +57 -4
- package/templates/fastapi-router.txt +198 -0
package/src/server/stdio.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
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((
|
|
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', (
|
|
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.
|
|
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 (
|
|
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)})
|