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/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,12 @@ 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 } : {}),
|
|
65
100
|
};
|
|
66
101
|
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
67
102
|
|
|
68
|
-
ensureGitignore(cwd);
|
|
69
103
|
return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
|
|
70
104
|
}
|
|
71
105
|
|
|
@@ -165,15 +199,19 @@ export function removeInjection(cwd, manifest) {
|
|
|
165
199
|
return results;
|
|
166
200
|
}
|
|
167
201
|
|
|
202
|
+
// Returns what was done ('created' | 'appended' | null) so the manifest can
|
|
203
|
+
// record it and `remove` can revert the exact edit (byte-for-byte promise).
|
|
168
204
|
function ensureGitignore(cwd) {
|
|
169
205
|
const gi = path.join(cwd, '.gitignore');
|
|
170
206
|
const line = '.sparda/';
|
|
171
207
|
if (fs.existsSync(gi)) {
|
|
172
208
|
const content = fs.readFileSync(gi, 'utf8');
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
209
|
+
if (content.split(/\r?\n/).includes(line)) return null;
|
|
210
|
+
fs.appendFileSync(gi, `\n${line}\n`);
|
|
211
|
+
return 'appended';
|
|
176
212
|
}
|
|
213
|
+
fs.writeFileSync(gi, `${line}\n`);
|
|
214
|
+
return 'created';
|
|
177
215
|
}
|
|
178
216
|
|
|
179
217
|
function atomicWrite(file, content) {
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
};
|
|
70
|
+
|
|
71
|
+
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
72
|
+
|
|
73
|
+
return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCmd }) {
|
|
77
|
+
const src = fs.readFileSync(entryAbs, 'utf8');
|
|
78
|
+
|
|
79
|
+
// backup first
|
|
80
|
+
const backupDir = path.join(cwd, '.sparda', 'backup');
|
|
81
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
82
|
+
fs.writeFileSync(path.join(backupDir, `${path.basename(entryAbs)}.${Date.now()}`), src);
|
|
83
|
+
|
|
84
|
+
// Determine if directory has __init__.py (relative vs absolute import)
|
|
85
|
+
const entryDir = path.dirname(entryAbs);
|
|
86
|
+
const hasInit = fs.existsSync(path.join(entryDir, '__init__.py'));
|
|
87
|
+
const importLine = hasInit
|
|
88
|
+
? 'from .sparda_router import sparda_router'
|
|
89
|
+
: 'from sparda_router import sparda_router';
|
|
90
|
+
|
|
91
|
+
// preserve the file's own line endings (Windows checkouts are CRLF)
|
|
92
|
+
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
93
|
+
|
|
94
|
+
// strip previous injection blocks (idempotence)
|
|
95
|
+
let body = src;
|
|
96
|
+
const blockRx = new RegExp(`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\r?\\n?`, 'g');
|
|
97
|
+
body = body.replace(blockRx, eol);
|
|
98
|
+
|
|
99
|
+
// Let's find the FastAPI() call assignment to inject right after it
|
|
100
|
+
// ([ \t]*) — NOT (\s*): \s matches newlines and would swallow blank lines/CR into the "indent" (E-007)
|
|
101
|
+
const match = body.match(/^([ \t]*)(\w+)\s*=\s*(?:\w+\.)?FastAPI\(/m);
|
|
102
|
+
if (!match) {
|
|
103
|
+
return {
|
|
104
|
+
injected: false,
|
|
105
|
+
manual: [importLine, 'app.include_router(sparda_router, prefix="/mcp")']
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const indent = match[1];
|
|
110
|
+
const appName = match[2];
|
|
111
|
+
|
|
112
|
+
// Prepare injection block
|
|
113
|
+
const useBlock = [
|
|
114
|
+
MARK_START,
|
|
115
|
+
`${indent}${importLine}`,
|
|
116
|
+
`${indent}${appName}.include_router(sparda_router, prefix="/mcp")`,
|
|
117
|
+
MARK_END
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// We find where the matching line starts and ends in order to insert right after it.
|
|
121
|
+
const lines = body.split(/\r?\n/);
|
|
122
|
+
let insertAt = -1;
|
|
123
|
+
const linePattern = new RegExp(`^[ \\t]*${appName}\\s*=\\s*(?:\\w+\\.)?FastAPI\\(`);
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
if (linePattern.test(lines[i])) {
|
|
126
|
+
insertAt = i + 1; // Insert AFTER this line
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (insertAt === -1) {
|
|
132
|
+
return {
|
|
133
|
+
injected: false,
|
|
134
|
+
manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
lines.splice(insertAt, 0, ...useBlock);
|
|
139
|
+
const out = lines.join(eol);
|
|
140
|
+
|
|
141
|
+
// Post-injection compilation check to avoid syntax errors
|
|
142
|
+
if (!verifyPythonSyntax(entryAbs, out, pythonCmd)) {
|
|
143
|
+
return {
|
|
144
|
+
injected: false,
|
|
145
|
+
manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
atomicWrite(entryAbs, out);
|
|
150
|
+
return { injected: true, manual: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function removeInjection(cwd, manifest, pythonCmd = 'python') {
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const relFile of manifest.injectedFiles ?? []) {
|
|
156
|
+
const abs = path.resolve(cwd, relFile);
|
|
157
|
+
if (!fs.existsSync(abs)) continue;
|
|
158
|
+
const src = fs.readFileSync(abs, 'utf8');
|
|
159
|
+
const blockRx = new RegExp(`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`, 'g');
|
|
160
|
+
const out = src.replace(blockRx, '');
|
|
161
|
+
|
|
162
|
+
if (verifyPythonSyntax(abs, out, pythonCmd)) {
|
|
163
|
+
atomicWrite(abs, out);
|
|
164
|
+
results.push({ file: relFile, ok: true });
|
|
165
|
+
} else {
|
|
166
|
+
results.push({ file: relFile, ok: false });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return results;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function verifyPythonSyntax(filePath, content, pythonCmd) {
|
|
173
|
+
const tmpFile = `${filePath}.syntax-check.py`;
|
|
174
|
+
try {
|
|
175
|
+
fs.writeFileSync(tmpFile, content);
|
|
176
|
+
const args = pythonCmd === 'py' ? ['-3', '-m', 'py_compile', tmpFile] : ['-m', 'py_compile', tmpFile];
|
|
177
|
+
const res = spawnSync(pythonCmd, args, { timeout: 2000 });
|
|
178
|
+
return res.status === 0;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
} finally {
|
|
182
|
+
try {
|
|
183
|
+
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
|
|
184
|
+
} catch {
|
|
185
|
+
// ignore
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Returns what was done ('created' | 'appended' | null) so the manifest can
|
|
191
|
+
// record it and `remove` can revert the exact edit (byte-for-byte promise).
|
|
192
|
+
function ensureGitignore(cwd) {
|
|
193
|
+
const gi = path.join(cwd, '.gitignore');
|
|
194
|
+
const line = '.sparda/';
|
|
195
|
+
if (fs.existsSync(gi)) {
|
|
196
|
+
const content = fs.readFileSync(gi, 'utf8');
|
|
197
|
+
if (content.split(/\r?\n/).includes(line)) return null;
|
|
198
|
+
fs.appendFileSync(gi, `\n${line}\n`);
|
|
199
|
+
return 'appended';
|
|
200
|
+
}
|
|
201
|
+
fs.writeFileSync(gi, `${line}\n`);
|
|
202
|
+
return 'created';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function atomicWrite(file, content) {
|
|
206
|
+
const tmp = `${file}.sparda-tmp`;
|
|
207
|
+
fs.writeFileSync(tmp, content);
|
|
208
|
+
fs.renameSync(tmp, file);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
@@ -0,0 +1,23 @@
|
|
|
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 passes)
|
|
6
|
+
// wrote into sparda.json: per-tool `enabled` overrides, the cached `semantic`
|
|
7
|
+
// enrichment and the `immune` memory (antibodies) survive as long as the
|
|
8
|
+
// tool's method+path are unchanged.
|
|
9
|
+
export function carryOverManifest(cwd, tools) {
|
|
10
|
+
let prev = null;
|
|
11
|
+
try {
|
|
12
|
+
prev = JSON.parse(fs.readFileSync(path.join(cwd, 'sparda.json'), 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
17
|
+
const old = prev?.tools?.[name];
|
|
18
|
+
if (old && typeof old.enabled === 'boolean' && old.method === t.method && old.path === t.path) {
|
|
19
|
+
t.enabled = old.enabled;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return prev;
|
|
23
|
+
}
|
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}`;
|
|
@@ -141,7 +159,8 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
141
159
|
|
|
142
160
|
function resolveRel(fromFile, spec) {
|
|
143
161
|
if (!spec.startsWith('.')) return null;
|
|
144
|
-
const
|
|
162
|
+
const cleanSpec = spec.replace(/\.(m?[jt]s|cjs)$/, '');
|
|
163
|
+
const base = path.resolve(path.dirname(fromFile), cleanSpec);
|
|
145
164
|
for (const cand of [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, `${base}.cjs`,
|
|
146
165
|
path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
|
|
147
166
|
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
|
|
@@ -172,6 +191,7 @@ export function toolNameFor(route, taken) {
|
|
|
172
191
|
let name = `${route.method}_${route.path}`
|
|
173
192
|
.toLowerCase()
|
|
174
193
|
.replace(/:(\w+)/g, 'by_$1')
|
|
194
|
+
.replace(/\{(\w+)\}/g, 'by_$1')
|
|
175
195
|
.replace(/[^a-z0-9]+/g, '_')
|
|
176
196
|
.replace(/^_+|_+$/g, '')
|
|
177
197
|
.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
|
+
}
|