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/LICENSE +89 -0
- package/README.md +56 -7
- package/package.json +6 -4
- package/src/commands/doctor.js +45 -7
- package/src/commands/hook.js +51 -0
- package/src/commands/init.js +34 -14
- package/src/commands/remove.js +50 -3
- package/src/commands/sync.js +49 -0
- package/src/detect.js +98 -2
- package/src/generator/express.js +47 -8
- package/src/generator/fastapi.js +212 -0
- package/src/generator/manifest.js +24 -0
- package/src/index.js +22 -3
- package/src/parser/express.js +69 -7
- package/src/parser/fastapi.js +26 -0
- package/src/parser/fastapi_extract.py +443 -0
- package/src/server/condenser.js +145 -0
- package/src/server/crystallize.js +89 -0
- package/src/server/idle.js +42 -0
- package/src/server/stdio.js +426 -29
- package/src/ui/style.js +66 -0
- package/templates/express-router.txt +104 -4
- package/templates/fastapi-router.txt +252 -0
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
|
-
|
|
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]);
|
package/src/generator/express.js
CHANGED
|
@@ -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
|
-
|
|
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__',
|
|
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 (
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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) {
|
package/src/parser/express.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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
|
+
}
|