sparda-mcp 0.1.0 → 0.4.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/src/detect.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // detect.js — framework, entry file, port detection (spec: blueprint 03-PARSING §A)
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
4
5
 
5
6
  const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
6
7
 
@@ -21,10 +22,102 @@ export function detectStack(cwd) {
21
22
  for (const f of ['requirements.txt', 'pyproject.toml']) {
22
23
  const p = path.join(cwd, f);
23
24
  if (fs.existsSync(p) && fs.readFileSync(p, 'utf8').toLowerCase().includes('fastapi')) {
24
- throw err('FastAPI detected — Python support lands next release.', 'Watch the repo: github.com/zyx77550/sparda');
25
+ const entryFile = findFastAPIEntry(cwd);
26
+ const port = detectFastAPIPort(cwd, entryFile);
27
+ const pythonCmd = detectPython();
28
+ return { framework: 'fastapi', entryFile, port, pythonCmd };
25
29
  }
26
30
  }
27
- throw err('No supported framework found (Express, FastAPI).', 'Run sparda inside your project root, next to package.json.');
31
+ throw err('No supported framework found (Express, FastAPI).', 'Run sparda-mcp inside your project root, next to package.json.');
32
+ }
33
+
34
+ function detectPython() {
35
+ const cmds = ['python3', 'python', 'py'];
36
+ for (const cmd of cmds) {
37
+ const args = cmd === 'py' ? ['-3', '--version'] : ['--version'];
38
+ try {
39
+ const res = spawnSync(cmd, args, { encoding: 'utf8', timeout: 2000 });
40
+ if (res.status === 0) {
41
+ const out = (res.stdout || res.stderr || '').trim();
42
+ const m = out.match(/Python\s+(\d+\.\d+\.\d+)/i);
43
+ if (m) {
44
+ const ver = m[1];
45
+ const [major, minor] = ver.split('.').map(Number);
46
+ if (major === 3 && minor >= 9) {
47
+ return cmd;
48
+ }
49
+ }
50
+ }
51
+ } catch {
52
+ // ignore
53
+ }
54
+ }
55
+ throw err('Python 3 (>= 3.9) introuvable dans le PATH.', 'Installe Python >= 3.9 pour utiliser SPARDA sur un projet FastAPI.');
56
+ }
57
+
58
+ function findFastAPIEntry(cwd) {
59
+ const candidates = [
60
+ 'main.py', 'app.py', 'src/main.py', 'app/main.py'
61
+ ];
62
+ for (const rel of candidates) {
63
+ const abs = path.resolve(cwd, rel);
64
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
65
+ const src = fs.readFileSync(abs, 'utf8');
66
+ if (src.includes('FastAPI(')) {
67
+ return path.relative(cwd, abs).split(path.sep).join('/');
68
+ }
69
+ }
70
+ }
71
+
72
+ const entry = searchPyFiles(cwd, cwd);
73
+ if (entry) {
74
+ return path.relative(cwd, entry).split(path.sep).join('/');
75
+ }
76
+
77
+ throw err('Could not locate your FastAPI entry file (the one calling FastAPI()).', 'Specify it manually, or make sure FastAPI() is declared in one of your python files.');
78
+ }
79
+
80
+ function searchPyFiles(dir, root, countRef = { val: 0 }) {
81
+ const EXCLUDE = new Set(['node_modules', '.git', 'venv', '.venv', 'tests', '__pycache__']);
82
+ let items;
83
+ try {
84
+ items = fs.readdirSync(dir);
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ for (const item of items) {
90
+ const abs = path.join(dir, item);
91
+ let stat;
92
+ try {
93
+ stat = fs.statSync(abs);
94
+ } catch {
95
+ continue;
96
+ }
97
+
98
+ if (stat.isDirectory()) {
99
+ if (EXCLUDE.has(item)) continue;
100
+ const found = searchPyFiles(abs, root, countRef);
101
+ if (found) return found;
102
+ } else if (stat.isFile() && item.endsWith('.py')) {
103
+ countRef.val++;
104
+ if (countRef.val > 200) return null;
105
+ const src = fs.readFileSync(abs, 'utf8');
106
+ if (src.includes('FastAPI(')) {
107
+ return abs;
108
+ }
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function detectFastAPIPort(cwd, entryFile) {
115
+ const abs = path.resolve(cwd, entryFile);
116
+ if (!fs.existsSync(abs)) return 8000;
117
+ const src = fs.readFileSync(abs, 'utf8');
118
+ const m = src.match(/uvicorn\.run\([^)]*port\s*=\s*(\d+)/s);
119
+ if (m) return Number(m[1]);
120
+ return 8000;
28
121
  }
29
122
 
30
123
  function findExpressEntry(cwd, pkg) {
@@ -77,6 +170,9 @@ function detectExpressPort(cwd, entryFile) {
77
170
  }
78
171
  }
79
172
  }
173
+ // env fallback literal, wrapped or not: Number(process.env.PORT ?? 4477), process.env.APP_PORT || 4488
174
+ m = src.match(/process\.env\.\w*PORT\w*\s*(?:\?\?|\|\|)\s*(\d{2,5})/i);
175
+ if (m) return Number(m[1]);
80
176
  // PORT env var or const PORT = 3000 pattern
81
177
  m = src.match(/(?:PORT|port)\s*=\s*(?:process\.env\.\w+\s*(?:\|\||\?\?)\s*)?(\d{2,5})/);
82
178
  if (m) return Number(m[1]);
@@ -5,13 +5,14 @@ import crypto from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parse } from '@babel/parser';
7
7
  import { toolNameFor } from '../parser/express.js';
8
+ import { carryOverManifest } from './manifest.js';
8
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
11
  const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
11
12
  const MARK_END = '// <<< sparda-injection <<<';
12
13
 
13
14
  export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
14
- const taken = new Set(['sparda_info', 'sparda_list_disabled_tools']);
15
+ const taken = new Set(['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context']);
15
16
  const tools = {};
16
17
  for (const r of routes) {
17
18
  const name = toolNameFor(r, taken);
@@ -25,8 +26,10 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
25
26
  confidence: r.confidence,
26
27
  };
27
28
  }
29
+ const prev = carryOverManifest(cwd, tools);
28
30
 
29
- const localKey = crypto.randomUUID();
31
+ // stable across re-runs so a running bridge/host pair never desyncs
32
+ const localKey = prev?.localKey ?? crypto.randomUUID();
30
33
  const entryAbs = path.resolve(cwd, entryFile);
31
34
  const entryDir = path.dirname(entryAbs);
32
35
  const ext = entryFile.endsWith('.ts') ? '.ts' : entryFile.endsWith('.mjs') ? '.mjs' : entryFile.endsWith('.cjs') ? '.cjs' : '.js';
@@ -36,20 +39,49 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
36
39
  // --- render router from template
37
40
  let tpl = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', 'express-router.txt'), 'utf8');
38
41
  const isESM = moduleType === 'esm';
42
+ const isTS = ext === '.ts';
43
+
44
+ let importLine = '';
45
+ let reqType = '';
46
+ let resType = '';
47
+ let nextType = '';
48
+
49
+ if (isTS) {
50
+ reqType = ': Request';
51
+ resType = ': Response';
52
+ nextType = ': NextFunction';
53
+ if (isESM) {
54
+ importLine = "import express, { Request, Response, NextFunction } from 'express';";
55
+ } else {
56
+ importLine = "import type { Request, Response, NextFunction } from 'express';\nconst express = require('express');";
57
+ }
58
+ } else {
59
+ importLine = isESM ? "import express from 'express';" : "const express = require('express');";
60
+ }
61
+
39
62
  tpl = tpl
40
- .replace('__IMPORT_LINE__', isESM ? "import express from 'express';" : "const express = require('express');")
63
+ .replace('__IMPORT_LINE__', importLine)
41
64
  .replace('__ROUTER_DECL__', isESM
42
65
  ? 'export const spardaRouter = express.Router();'
43
66
  : 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };')
44
67
  .replace('__JSON_MW__', 'express.json()')
45
68
  .replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
46
69
  .replace('__LOCAL_KEY__', localKey)
47
- .replace('__PORT__', String(port));
70
+ .replace('__PORT__', String(port))
71
+ .replace(/__REQ_TYPE__/g, reqType)
72
+ .replace(/__RES_TYPE__/g, resType)
73
+ .replace(/__NEXT_TYPE__/g, nextType)
74
+ .replace(/__STATS_TYPE__/g, isTS ? ': Record<string, any>' : '')
75
+ .replace(/__EVENTS_TYPE__/g, isTS ? ': any[]' : '')
76
+ .replace(/__ANY_TYPE__/g, isTS ? ': any' : '');
48
77
  atomicWrite(routerAbs, tpl);
49
78
 
50
79
  // --- inject into entry file (line-based via AST positions)
51
80
  const injection = injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd });
52
81
 
82
+ // record what init did to .gitignore so `remove` can revert it exactly (hard rule #4, E-010)
83
+ const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
84
+
53
85
  // --- manifest
54
86
  const manifest = {
55
87
  version: 1,
@@ -62,10 +94,13 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
62
94
  injectedFiles: injection.injected ? [entryFile] : [],
63
95
  createdAt: new Date().toISOString(),
64
96
  tools: Object.fromEntries(Object.entries(tools).map(([k, v]) => [k, { method: v.method, path: v.path, enabled: v.enabled }])),
97
+ ...(gitignore ? { gitignore } : {}),
98
+ ...(prev?.semantic ? { semantic: prev.semantic } : {}),
99
+ ...(prev?.immune ? { immune: prev.immune } : {}),
100
+ ...(prev?.labs ? { labs: prev.labs } : {}),
65
101
  };
66
102
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
67
103
 
68
- ensureGitignore(cwd);
69
104
  return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
70
105
  }
71
106
 
@@ -165,15 +200,19 @@ export function removeInjection(cwd, manifest) {
165
200
  return results;
166
201
  }
167
202
 
203
+ // Returns what was done ('created' | 'appended' | null) so the manifest can
204
+ // record it and `remove` can revert the exact edit (byte-for-byte promise).
168
205
  function ensureGitignore(cwd) {
169
206
  const gi = path.join(cwd, '.gitignore');
170
207
  const line = '.sparda/';
171
208
  if (fs.existsSync(gi)) {
172
209
  const content = fs.readFileSync(gi, 'utf8');
173
- if (!content.split(/\r?\n/).includes(line)) fs.appendFileSync(gi, `\n${line}\n`);
174
- } else {
175
- fs.writeFileSync(gi, `${line}\n`);
210
+ if (content.split(/\r?\n/).includes(line)) return null;
211
+ fs.appendFileSync(gi, `\n${line}\n`);
212
+ return 'appended';
176
213
  }
214
+ fs.writeFileSync(gi, `${line}\n`);
215
+ return 'created';
177
216
  }
178
217
 
179
218
  function atomicWrite(file, content) {
@@ -0,0 +1,212 @@
1
+ // generator/fastapi.js — router generation + python injection (spec: blueprint 04-GENERATION §A)
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { toolNameFor } from '../parser/express.js';
8
+ import { carryOverManifest } from './manifest.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
12
+ const MARK_END = '# <<< sparda-injection <<<';
13
+
14
+ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, pythonCmd = 'python' }) {
15
+ const taken = new Set(['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context']);
16
+ const tools = {};
17
+ for (const r of routes) {
18
+ const name = toolNameFor(r, taken);
19
+ tools[name] = {
20
+ method: r.method.toUpperCase(),
21
+ path: r.path,
22
+ enabled: !r.mutating, // write-safety: mutating tools off by default
23
+ pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
24
+ description: r.description,
25
+ params: r.params,
26
+ confidence: r.confidence,
27
+ };
28
+ }
29
+ const prev = carryOverManifest(cwd, tools);
30
+
31
+ // stable across re-runs so a running bridge/host pair never desyncs
32
+ const localKey = prev?.localKey ?? crypto.randomUUID();
33
+ const entryAbs = path.resolve(cwd, entryFile);
34
+ const entryDir = path.dirname(entryAbs);
35
+ const routerFileName = 'sparda_router.py';
36
+ const routerAbs = path.join(entryDir, routerFileName);
37
+
38
+ // --- render router from template
39
+ let tpl = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', 'fastapi-router.txt'), 'utf8');
40
+ tpl = tpl
41
+ // double-stringify: a JSON string literal is also a valid Python string literal,
42
+ // and json.loads() in the template turns it into real Python values (E-009)
43
+ .replace('__TOOLS_JSON__', JSON.stringify(JSON.stringify(tools)))
44
+ .replace('__LOCAL_KEY__', localKey)
45
+ .replace('__PORT__', String(port));
46
+
47
+ atomicWrite(routerAbs, tpl);
48
+
49
+ // --- inject into entry file (regex-based with compilation safety check)
50
+ const injection = injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCmd });
51
+
52
+ // record what init did to .gitignore so `remove` can revert it exactly (hard rule #4, E-010)
53
+ const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
54
+
55
+ // --- manifest
56
+ const manifest = {
57
+ version: 1,
58
+ framework: 'fastapi',
59
+ entryFile,
60
+ port,
61
+ localKey,
62
+ generatedFiles: [path.relative(cwd, routerAbs).split(path.sep).join('/')],
63
+ injectedFiles: injection.injected ? [entryFile] : [],
64
+ createdAt: new Date().toISOString(),
65
+ tools: Object.fromEntries(Object.entries(tools).map(([k, v]) => [k, { method: v.method, path: v.path, enabled: v.enabled }])),
66
+ ...(gitignore ? { gitignore } : {}),
67
+ ...(prev?.semantic ? { semantic: prev.semantic } : {}),
68
+ ...(prev?.immune ? { immune: prev.immune } : {}),
69
+ ...(prev?.labs ? { labs: prev.labs } : {}),
70
+ };
71
+
72
+ atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
73
+
74
+ return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
75
+ }
76
+
77
+ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCmd }) {
78
+ const src = fs.readFileSync(entryAbs, 'utf8');
79
+
80
+ // backup first
81
+ const backupDir = path.join(cwd, '.sparda', 'backup');
82
+ fs.mkdirSync(backupDir, { recursive: true });
83
+ fs.writeFileSync(path.join(backupDir, `${path.basename(entryAbs)}.${Date.now()}`), src);
84
+
85
+ // Determine if directory has __init__.py (relative vs absolute import)
86
+ const entryDir = path.dirname(entryAbs);
87
+ const hasInit = fs.existsSync(path.join(entryDir, '__init__.py'));
88
+ const importLine = hasInit
89
+ ? 'from .sparda_router import sparda_router'
90
+ : 'from sparda_router import sparda_router';
91
+
92
+ // preserve the file's own line endings (Windows checkouts are CRLF)
93
+ const eol = src.includes('\r\n') ? '\r\n' : '\n';
94
+
95
+ // strip previous injection blocks (idempotence)
96
+ let body = src;
97
+ const blockRx = new RegExp(`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\r?\\n?`, 'g');
98
+ body = body.replace(blockRx, eol);
99
+
100
+ // Let's find the FastAPI() call assignment to inject right after it
101
+ // ([ \t]*) — NOT (\s*): \s matches newlines and would swallow blank lines/CR into the "indent" (E-007)
102
+ const match = body.match(/^([ \t]*)(\w+)\s*=\s*(?:\w+\.)?FastAPI\(/m);
103
+ if (!match) {
104
+ return {
105
+ injected: false,
106
+ manual: [importLine, 'app.include_router(sparda_router, prefix="/mcp")']
107
+ };
108
+ }
109
+
110
+ const indent = match[1];
111
+ const appName = match[2];
112
+
113
+ // Prepare injection block
114
+ const useBlock = [
115
+ MARK_START,
116
+ `${indent}${importLine}`,
117
+ `${indent}${appName}.include_router(sparda_router, prefix="/mcp")`,
118
+ MARK_END
119
+ ];
120
+
121
+ // We find where the matching line starts and ends in order to insert right after it.
122
+ const lines = body.split(/\r?\n/);
123
+ let insertAt = -1;
124
+ const linePattern = new RegExp(`^[ \\t]*${appName}\\s*=\\s*(?:\\w+\\.)?FastAPI\\(`);
125
+ for (let i = 0; i < lines.length; i++) {
126
+ if (linePattern.test(lines[i])) {
127
+ insertAt = i + 1; // Insert AFTER this line
128
+ break;
129
+ }
130
+ }
131
+
132
+ if (insertAt === -1) {
133
+ return {
134
+ injected: false,
135
+ manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
136
+ };
137
+ }
138
+
139
+ lines.splice(insertAt, 0, ...useBlock);
140
+ const out = lines.join(eol);
141
+
142
+ // Post-injection compilation check to avoid syntax errors
143
+ if (!verifyPythonSyntax(entryAbs, out, pythonCmd)) {
144
+ return {
145
+ injected: false,
146
+ manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
147
+ };
148
+ }
149
+
150
+ atomicWrite(entryAbs, out);
151
+ return { injected: true, manual: null };
152
+ }
153
+
154
+ export function removeInjection(cwd, manifest, pythonCmd = 'python') {
155
+ const results = [];
156
+ for (const relFile of manifest.injectedFiles ?? []) {
157
+ const abs = path.resolve(cwd, relFile);
158
+ if (!fs.existsSync(abs)) continue;
159
+ const src = fs.readFileSync(abs, 'utf8');
160
+ const blockRx = new RegExp(`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`, 'g');
161
+ const out = src.replace(blockRx, '');
162
+
163
+ if (verifyPythonSyntax(abs, out, pythonCmd)) {
164
+ atomicWrite(abs, out);
165
+ results.push({ file: relFile, ok: true });
166
+ } else {
167
+ results.push({ file: relFile, ok: false });
168
+ }
169
+ }
170
+ return results;
171
+ }
172
+
173
+ function verifyPythonSyntax(filePath, content, pythonCmd) {
174
+ const tmpFile = `${filePath}.syntax-check.py`;
175
+ try {
176
+ fs.writeFileSync(tmpFile, content);
177
+ const args = pythonCmd === 'py' ? ['-3', '-m', 'py_compile', tmpFile] : ['-m', 'py_compile', tmpFile];
178
+ const res = spawnSync(pythonCmd, args, { timeout: 2000 });
179
+ return res.status === 0;
180
+ } catch {
181
+ return false;
182
+ } finally {
183
+ try {
184
+ if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
185
+ } catch {
186
+ // ignore
187
+ }
188
+ }
189
+ }
190
+
191
+ // Returns what was done ('created' | 'appended' | null) so the manifest can
192
+ // record it and `remove` can revert the exact edit (byte-for-byte promise).
193
+ function ensureGitignore(cwd) {
194
+ const gi = path.join(cwd, '.gitignore');
195
+ const line = '.sparda/';
196
+ if (fs.existsSync(gi)) {
197
+ const content = fs.readFileSync(gi, 'utf8');
198
+ if (content.split(/\r?\n/).includes(line)) return null;
199
+ fs.appendFileSync(gi, `\n${line}\n`);
200
+ return 'appended';
201
+ }
202
+ fs.writeFileSync(gi, `${line}\n`);
203
+ return 'created';
204
+ }
205
+
206
+ function atomicWrite(file, content) {
207
+ const tmp = `${file}.sparda-tmp`;
208
+ fs.writeFileSync(tmp, content);
209
+ fs.renameSync(tmp, file);
210
+ }
211
+
212
+ function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
@@ -0,0 +1,24 @@
1
+ // generator/manifest.js — carry user state across re-runs of `sparda init`
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // Re-running init must not wipe what the user (or the semantic/immune/Labs
6
+ // passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
7
+ // `semantic` enrichment, the `immune` memory (antibodies) and the `labs`
8
+ // state (opt-in flags + observed circuits) survive as long as the tool's
9
+ // method+path are unchanged.
10
+ export function carryOverManifest(cwd, tools) {
11
+ let prev = null;
12
+ try {
13
+ prev = JSON.parse(fs.readFileSync(path.join(cwd, 'sparda.json'), 'utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+ for (const [name, t] of Object.entries(tools)) {
18
+ const old = prev?.tools?.[name];
19
+ if (old && typeof old.enabled === 'boolean' && old.method === t.method && old.path === t.path) {
20
+ t.enabled = old.enabled;
21
+ }
22
+ }
23
+ return prev;
24
+ }
package/src/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // SPARDA — Turn any codebase into an MCP server. Residual Labs.
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
3
6
  const [, , cmd, ...rest] = process.argv;
4
7
 
5
8
  const flags = new Set(rest.filter((a) => a.startsWith('--')));
@@ -11,6 +14,7 @@ const getOpt = (name, dflt) => {
11
14
  const opts = {
12
15
  yes: flags.has('--yes') || flags.has('-y'),
13
16
  verbose: flags.has('--verbose'),
17
+ quiet: flags.has('--quiet'),
14
18
  port: getOpt('port', null),
15
19
  cwd: process.cwd(),
16
20
  };
@@ -34,19 +38,34 @@ try {
34
38
  }
35
39
  case 'doctor': {
36
40
  const { runDoctor } = await import('./commands/doctor.js');
37
- await runDoctor(opts);
41
+ const { healthy } = await runDoctor(opts);
42
+ if (!healthy) process.exitCode = 1; // let scripts/CI gate on doctor
43
+ break;
44
+ }
45
+ case 'sync': {
46
+ const { runSync } = await import('./commands/sync.js');
47
+ await runSync(opts);
48
+ break;
49
+ }
50
+ case 'hook': {
51
+ const { runHook } = await import('./commands/hook.js');
52
+ await runHook(opts);
38
53
  break;
39
54
  }
40
55
  default:
41
- console.log(`SPARDA v0.1.0 — Turn any codebase into an MCP server.
56
+ console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
42
57
 
43
58
  Usage:
44
59
  npx sparda-mcp init Scan project, generate & inject the MCP router
45
60
  npx sparda-mcp dev Run the MCP stdio bridge (connect Claude Desktop)
61
+ npx sparda-mcp sync Re-sync the router after route changes (no prompts)
62
+ npx sparda-mcp hook Install the git sentinel (auto-sync after commits)
46
63
  npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
47
64
  npx sparda-mcp doctor Diagnose your setup
48
65
 
49
- Flags: --yes (skip prompts) --port <n> --verbose`);
66
+ Flags: --yes (skip prompts) --port <n> --quiet --verbose
67
+
68
+ By Residual Labs — residual-labs.fr`);
50
69
  process.exit(cmd ? 1 : 0);
51
70
  }
52
71
  } catch (err) {
@@ -50,16 +50,29 @@ export function parseExpressProject(cwd, entryFile) {
50
50
  traverse(ast, {
51
51
  VariableDeclarator(p) {
52
52
  const init = p.node.init;
53
- const name = p.node.id?.name;
54
- if (!name || !init) return;
53
+ if (!init) return;
55
54
  if (init.type === 'CallExpression') {
56
55
  const callee = init.callee;
57
- if (callee.type === 'Identifier' && callee.name === 'express') { appVars.add(name); exprStarts.add(p.node.loc.end.line); }
58
- if (callee.type === 'Identifier' && callee.name === 'Router') routerVars.add(name);
59
- if (callee.type === 'MemberExpression' && callee.property?.name === 'Router') routerVars.add(name);
60
56
  if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments[0]?.type === 'StringLiteral') {
61
57
  const resolved = resolveRel(absFile, init.arguments[0].value);
62
- if (resolved) importMap.set(name, resolved);
58
+ if (resolved) {
59
+ const id = p.node.id;
60
+ if (id.type === 'Identifier') {
61
+ importMap.set(id.name, resolved);
62
+ } else if (id.type === 'ObjectPattern') {
63
+ for (const prop of id.properties) {
64
+ if (prop.type === 'ObjectProperty' && prop.value.type === 'Identifier') {
65
+ importMap.set(prop.value.name, resolved);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ const name = p.node.id?.name;
72
+ if (name) {
73
+ if (callee.type === 'Identifier' && callee.name === 'express') { appVars.add(name); exprStarts.add(p.node.loc.end.line); }
74
+ if (callee.type === 'Identifier' && callee.name === 'Router') routerVars.add(name);
75
+ if (callee.type === 'MemberExpression' && callee.property?.name === 'Router') routerVars.add(name);
63
76
  }
64
77
  }
65
78
  },
@@ -97,6 +110,11 @@ export function parseExpressProject(cwd, entryFile) {
97
110
  }
98
111
 
99
112
  const fullPath = joinPath(prefix, pathArg.value);
113
+ if (fullPath.startsWith('/mcp')) {
114
+ skipped.push({ reason: `self-referential path ${fullPath} blocked`, file: rel(absFile), line: p.node.loc?.start.line });
115
+ return;
116
+ }
117
+
100
118
  const handler = args[args.length - 1];
101
119
  const handlerName = handler?.type === 'Identifier' ? handler.name
102
120
  : handler?.id?.name ?? `anonymous_${routes.length + 1}`;
@@ -111,6 +129,14 @@ export function parseExpressProject(cwd, entryFile) {
111
129
 
112
130
  const mutating = method !== 'get';
113
131
  const params = [...pathParams];
132
+ // query params: handlers reveal them as req.query.X / destructured req.query —
133
+ // without this the AI cannot discover ?limit=… filters at all (E2E P2 finding)
134
+ const taken = new Set(params.map((x) => x.name));
135
+ for (const q of queryParamsOf(p)) {
136
+ if (taken.has(q)) continue;
137
+ taken.add(q);
138
+ params.push({ name: q, in: 'query', type: 'string', required: false, description: 'query parameter' });
139
+ }
114
140
  let confidence = 'high';
115
141
  if (mutating) {
116
142
  params.push({ name: 'body', in: 'body', type: 'object', required: false, description: 'JSON body — schema not statically detected' });
@@ -141,7 +167,8 @@ export function parseExpressProject(cwd, entryFile) {
141
167
 
142
168
  function resolveRel(fromFile, spec) {
143
169
  if (!spec.startsWith('.')) return null;
144
- const base = path.resolve(path.dirname(fromFile), spec);
170
+ const cleanSpec = spec.replace(/\.(m?[jt]s|cjs)$/, '');
171
+ const base = path.resolve(path.dirname(fromFile), cleanSpec);
145
172
  for (const cand of [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, `${base}.cjs`,
146
173
  path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
147
174
  if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
@@ -153,6 +180,40 @@ export function parseExpressProject(cwd, entryFile) {
153
180
  }
154
181
  }
155
182
 
183
+ // Query params are invisible in a route's signature; only the handler body
184
+ // reveals them. Covers the two canonical shapes — req.query.x / req.query['x']
185
+ // and `const { x } = req.query` — on inline handlers only (an Identifier
186
+ // handler lives elsewhere; guessing is worse than staying silent). Bounded.
187
+ function queryParamsOf(callPath) {
188
+ const reqNames = new Set();
189
+ for (const a of callPath.node.arguments) {
190
+ if ((a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression') && a.params[0]?.type === 'Identifier') {
191
+ reqNames.add(a.params[0].name);
192
+ }
193
+ }
194
+ const found = [];
195
+ if (!reqNames.size) return found;
196
+ const isReqQuery = (n) => n?.type === 'MemberExpression' && !n.computed &&
197
+ n.object?.type === 'Identifier' && reqNames.has(n.object.name) &&
198
+ n.property?.type === 'Identifier' && n.property.name === 'query';
199
+ callPath.traverse({
200
+ MemberExpression(q) {
201
+ const n = q.node;
202
+ if (!isReqQuery(n.object) || found.length >= 15) return;
203
+ if (!n.computed && n.property.type === 'Identifier') found.push(n.property.name);
204
+ else if (n.computed && n.property.type === 'StringLiteral') found.push(n.property.value);
205
+ },
206
+ VariableDeclarator(q) {
207
+ if (q.node.id.type !== 'ObjectPattern' || !isReqQuery(q.node.init)) return;
208
+ for (const prop of q.node.id.properties) {
209
+ if (found.length >= 15) break;
210
+ if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') found.push(prop.key.name);
211
+ }
212
+ },
213
+ });
214
+ return [...new Set(found)];
215
+ }
216
+
156
217
  function joinPath(prefix, p) {
157
218
  const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
158
219
  return joined.startsWith('/') ? joined : `/${joined}`;
@@ -172,6 +233,7 @@ export function toolNameFor(route, taken) {
172
233
  let name = `${route.method}_${route.path}`
173
234
  .toLowerCase()
174
235
  .replace(/:(\w+)/g, 'by_$1')
236
+ .replace(/\{(\w+)\}/g, 'by_$1')
175
237
  .replace(/[^a-z0-9]+/g, '_')
176
238
  .replace(/^_+|_+$/g, '')
177
239
  .slice(0, 60) || 'tool';
@@ -0,0 +1,26 @@
1
+ // parser/fastapi.js — JS wrapper executing Python AST extractor (spec: blueprint 03-PARSING §C)
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export function parseFastAPIProject(cwd, entryFile, pythonCmd = 'python') {
9
+ const extractorScript = path.resolve(__dirname, 'fastapi_extract.py');
10
+ const res = spawnSync(pythonCmd, [extractorScript, entryFile, cwd], { cwd, encoding: 'utf8' });
11
+
12
+ if (res.status !== 0) {
13
+ const errorMsg = (res.stderr || res.stdout || '').trim();
14
+ throw new Error(`FastAPI extraction process failed: ${errorMsg}`);
15
+ }
16
+
17
+ try {
18
+ const payload = JSON.parse(res.stdout.trim());
19
+ if (payload.error) {
20
+ throw new Error(payload.error);
21
+ }
22
+ return { routes: payload.routes || [], skipped: payload.skipped || [], entryAppVars: payload.entryAppVars || [] };
23
+ } catch (err) {
24
+ throw new Error(`Failed to parse Python extractor output: ${err.message}. Raw output: ${res.stdout}`);
25
+ }
26
+ }