sparda-mcp 0.1.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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # SPARDA
2
+
3
+ **Turn any codebase into an MCP server with one command.**
4
+
5
+ ```bash
6
+ npx sparda-mcp init # scan your Express app, generate + inject the MCP router
7
+ npx sparda-mcp dev # MCP stdio bridge — connect Claude Desktop / Claude Code
8
+ ```
9
+
10
+ Your software, AI-controlled, in under 3 minutes. No config. No OpenAPI spec. No infra.
11
+
12
+ ## How it works
13
+ 1. `npx sparda-mcp init` parses your codebase (AST), extracts every route, and injects a tiny marked router (`/mcp`) into your app — fully reversible with `npx sparda-mcp remove`.
14
+ 2. Tool calls run **inside your live app process** — warm DB pools, real auth chain, real data.
15
+ 3. Write tools (POST/PUT/DELETE) are **disabled by default** (write-safety). You opt in per tool in `sparda.json`.
16
+ 4. Suspicious docstrings are purged before they ever reach the AI (prompt-injection defense).
17
+
18
+ ## v0 supports
19
+ Express 4/5 (JS/TS, ESM/CJS). FastAPI next. Vote for your framework in the pinned issue.
20
+
21
+ ## Security posture (v0, honest)
22
+ - 4 runtime dependencies, exact-pinned.
23
+ - Local key on every router call; loop protection; 30s timeouts; 8KB output truncation.
24
+ - AST-positioned injection with backup + post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
25
+
26
+ MIT — by [Residual Labs](https://residual-labs.fr)
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "sparda-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sparda": "./src/index.js"
8
+ },
9
+ "scripts": {
10
+ "test": "vitest run"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "templates",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "claude",
24
+ "ai",
25
+ "express",
26
+ "fastapi",
27
+ "codegen"
28
+ ],
29
+ "author": "Residual Labs (residual-labs.fr)",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "@babel/parser": "7.26.5",
33
+ "@babel/traverse": "7.26.5",
34
+ "@clack/prompts": "0.9.1",
35
+ "@modelcontextprotocol/sdk": "1.12.0"
36
+ },
37
+ "devDependencies": {
38
+ "vitest": "^3.2.6"
39
+ }
40
+ }
@@ -0,0 +1,5 @@
1
+ // commands/dev.js
2
+ import { startStdioBridge } from '../server/stdio.js';
3
+ export async function runDev(opts) {
4
+ await startStdioBridge({ cwd: opts.cwd, portOverride: opts.port });
5
+ }
@@ -0,0 +1,34 @@
1
+ // commands/doctor.js
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { detectStack } from '../detect.js';
5
+
6
+ export async function runDoctor(opts) {
7
+ console.log('SPARDA doctor');
8
+ const node = process.versions.node;
9
+ console.log(` ${Number(node.split('.')[0]) >= 18 ? '✓' : '✗'} Node ${node} (≥18 required)`);
10
+ let stack = null;
11
+ try {
12
+ stack = detectStack(opts.cwd);
13
+ console.log(` ✓ Framework: ${stack.framework} — entry: ${stack.entryFile} — port: ${stack.port}`);
14
+ } catch (e) {
15
+ console.log(` ✗ ${e.message}`);
16
+ }
17
+ const manifest = path.join(opts.cwd, 'sparda.json');
18
+ if (fs.existsSync(manifest)) {
19
+ try {
20
+ const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
21
+ console.log(` ✓ sparda.json valid (${Object.keys(m.tools ?? {}).length} tools)`);
22
+ if (stack) {
23
+ try {
24
+ const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, { headers: { 'x-sparda-key': m.localKey }, signal: AbortSignal.timeout(1500) });
25
+ console.log(r.ok ? ` ✓ Host app reachable on :${m.port}, router responding` : ` ✗ Host app on :${m.port} answered ${r.status}`);
26
+ } catch {
27
+ console.log(` ✗ Host app on :${m.port} — NOT reachable\n → start it with: npm run dev`);
28
+ }
29
+ }
30
+ } catch { console.log(' ✗ sparda.json invalid JSON'); }
31
+ } else {
32
+ console.log(' · sparda.json not found (run `npx sparda-mcp init`)');
33
+ }
34
+ }
@@ -0,0 +1,79 @@
1
+ // commands/init.js
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import * as p from '@clack/prompts';
5
+ import { detectStack } from '../detect.js';
6
+ import { parseExpressProject } from '../parser/express.js';
7
+ import { sanitizeDescription } from '../security/sanitize.js';
8
+ import { generateExpress } from '../generator/express.js';
9
+
10
+ export async function runInit(opts) {
11
+ const t0 = Date.now();
12
+ p.intro('SPARDA v0.1.0');
13
+
14
+ const s = p.spinner();
15
+ s.start('Scanning project...');
16
+ const stack = detectStack(opts.cwd);
17
+ const { routes, skipped } = parseExpressProject(opts.cwd, stack.entryFile);
18
+ s.stop(`Stack detected: Express (${stack.moduleType.toUpperCase()}) — entry: ${stack.entryFile}, port: ${stack.port}`);
19
+
20
+ if (routes.length === 0) {
21
+ throw Object.assign(new Error('0 routes extracted.'), { code: 'USER', hint: 'SPARDA found Express but no literal-path routes. Dynamic routing is not supported in v0.' });
22
+ }
23
+
24
+ let flaggedCount = 0;
25
+ for (const r of routes) {
26
+ const { text, flagged } = sanitizeDescription(r.description, `${r.method.toUpperCase()} ${r.path}`);
27
+ r.description = text;
28
+ if (flagged) flaggedCount++;
29
+ }
30
+
31
+ const high = routes.filter((r) => r.confidence === 'high').length;
32
+ p.log.info(`${routes.length} routes found — ${high} high confidence, ${routes.length - high} partial`);
33
+
34
+ const preview = routes.slice(0, 8).map((r) =>
35
+ `${r.mutating ? '✗' : '✓'} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? ' (disabled: write-safety)' : ''}`
36
+ ).join('\n');
37
+ p.note(preview + (routes.length > 8 ? `\n... (+${routes.length - 8} more)` : ''), 'TOOLS TO GENERATE');
38
+
39
+ if (flaggedCount) p.log.warn(`${flaggedCount} suspicious docstring(s) purged (prompt-injection defense)`);
40
+ if (skipped.length) p.log.warn(`${skipped.length} route(s) skipped — details in .sparda/scan-report.json`);
41
+
42
+ let inject = true;
43
+ if (!opts.yes) {
44
+ const answer = await p.select({
45
+ message: `Inject the MCP router into ${stack.entryFile}?`,
46
+ options: [
47
+ { value: true, label: 'Yes, inject (adds a marked block, reversible via `sparda remove`)' },
48
+ { value: false, label: 'No, generate files only (I will add 2 lines manually)' },
49
+ ],
50
+ });
51
+ if (p.isCancel(answer)) { p.cancel('Aborted.'); process.exit(1); }
52
+ inject = answer;
53
+ }
54
+
55
+ const result = generateExpress({ cwd: opts.cwd, entryFile: stack.entryFile, moduleType: stack.moduleType, port: stack.port, routes });
56
+
57
+ fs.mkdirSync(path.join(opts.cwd, '.sparda'), { recursive: true });
58
+ fs.writeFileSync(path.join(opts.cwd, '.sparda', 'scan-report.json'), JSON.stringify({ routes, skipped }, null, 2));
59
+
60
+ p.log.success(`Generated ${result.routerFile}`);
61
+ if (inject && result.injection.injected) p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
62
+ else if (result.injection.manual) {
63
+ p.note(result.injection.manual.join('\n'), `Add these 2 lines to ${stack.entryFile} (after app = express())`);
64
+ }
65
+ p.log.success('Wrote sparda.json');
66
+
67
+ const cfg = JSON.stringify({ [path.basename(opts.cwd)]: { command: 'npx', args: ['sparda-mcp', 'dev'], cwd: opts.cwd } }, null, 2);
68
+ p.outro(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.
69
+
70
+ Next steps:
71
+ 1. Start your app: npm run dev
72
+ 2. Start the MCP bridge: npx sparda-mcp dev
73
+ 3. Add to Claude Desktop config (claude_desktop_config.json):
74
+
75
+ ${cfg.split('\n').map((l) => ' ' + l).join('\n')}
76
+
77
+ Write tools (POST/PUT/DELETE) are disabled by default.
78
+ Enable them in sparda.json, then re-run \`npx sparda-mcp init --yes\`.`);
79
+ }
@@ -0,0 +1,32 @@
1
+ // commands/remove.js
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import * as p from '@clack/prompts';
5
+ import { removeInjection } from '../generator/express.js';
6
+
7
+ export async function runRemove(opts) {
8
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
9
+ if (!fs.existsSync(manifestPath)) {
10
+ throw Object.assign(new Error('sparda.json not found — nothing to remove.'), { code: 'USER' });
11
+ }
12
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
13
+
14
+ if (!opts.yes) {
15
+ const ok = await p.confirm({ message: 'Remove SPARDA from this project? (deletes generated files + injected block)' });
16
+ if (p.isCancel(ok) || !ok) { p.cancel('Aborted.'); process.exit(1); }
17
+ }
18
+
19
+ const results = removeInjection(opts.cwd, manifest);
20
+ for (const r of results) {
21
+ if (r.ok) console.log(`✓ Removed injection from ${r.file} (file still parses)`);
22
+ else console.log(`✗ Could not safely remove from ${r.file} — restore from .sparda/backup/`);
23
+ }
24
+ for (const f of manifest.generatedFiles ?? []) {
25
+ const abs = path.resolve(opts.cwd, f);
26
+ if (fs.existsSync(abs)) { fs.rmSync(abs); console.log(`✓ Deleted ${f}`); }
27
+ }
28
+ fs.rmSync(manifestPath);
29
+ fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });
30
+ console.log('✓ Deleted sparda.json and .sparda/');
31
+ console.log('\nSPARDA removed. `git diff` should be clean (minus a .gitignore line).');
32
+ }
package/src/detect.js ADDED
@@ -0,0 +1,84 @@
1
+ // detect.js — framework, entry file, port detection (spec: blueprint 03-PARSING §A)
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
6
+
7
+ export function detectStack(cwd) {
8
+ const pkgPath = path.join(cwd, 'package.json');
9
+ if (fs.existsSync(pkgPath)) {
10
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
11
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
12
+ if (deps.express) {
13
+ const entryFile = findExpressEntry(cwd, pkg);
14
+ const moduleType = detectModuleType(cwd, pkg, entryFile);
15
+ const port = detectExpressPort(cwd, entryFile);
16
+ return { framework: 'express', entryFile, moduleType, port, expressVersion: deps.express };
17
+ }
18
+ const known = ['@nestjs/core', 'next', 'fastify', 'koa'].find((d) => deps[d]);
19
+ if (known) throw err(`${known} detected — not supported yet. Express & FastAPI only in v0.`, '+1 the framework vote: github.com/zyx77550/sparda/issues/1');
20
+ }
21
+ for (const f of ['requirements.txt', 'pyproject.toml']) {
22
+ const p = path.join(cwd, f);
23
+ 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
+ }
26
+ }
27
+ throw err('No supported framework found (Express, FastAPI).', 'Run sparda inside your project root, next to package.json.');
28
+ }
29
+
30
+ function findExpressEntry(cwd, pkg) {
31
+ const candidates = [];
32
+ if (pkg.main) candidates.push(pkg.main);
33
+ for (const script of [pkg.scripts?.dev, pkg.scripts?.start]) {
34
+ if (!script) continue;
35
+ const m = script.match(/(\S+\.(?:m?[tj]s|cjs))\s*$/);
36
+ if (m) candidates.push(m[1]);
37
+ }
38
+ candidates.push(
39
+ 'src/app.ts', 'src/server.ts', 'src/index.ts', 'app.ts', 'server.ts', 'index.ts',
40
+ 'src/app.js', 'src/server.js', 'src/index.js', 'app.js', 'server.js', 'index.js',
41
+ 'src/app.mjs', 'app.mjs', 'index.mjs',
42
+ );
43
+ for (const rel of candidates) {
44
+ const abs = path.resolve(cwd, rel);
45
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
46
+ const src = fs.readFileSync(abs, 'utf8');
47
+ if (/express\s*\(/.test(src)) return path.relative(cwd, abs).split(path.sep).join('/');
48
+ }
49
+ }
50
+ throw err('Could not locate your Express entry file (the one calling express()).', 'Re-run from the project root, or open an issue with your layout.');
51
+ }
52
+
53
+ function detectModuleType(cwd, pkg, entryFile) {
54
+ if (entryFile.endsWith('.mjs')) return 'esm';
55
+ if (entryFile.endsWith('.cjs')) return 'cjs';
56
+ if (pkg.type === 'module' || entryFile.endsWith('.ts')) {
57
+ const src = fs.readFileSync(path.join(cwd, entryFile), 'utf8');
58
+ if (/^\s*(const|let|var)\s+\w+\s*=\s*require\(/m.test(src) && !/^\s*import\s/m.test(src)) return 'cjs';
59
+ return 'esm';
60
+ }
61
+ const src = fs.readFileSync(path.join(cwd, entryFile), 'utf8');
62
+ return /^\s*import\s.+from\s/m.test(src) ? 'esm' : 'cjs';
63
+ }
64
+
65
+ function detectExpressPort(cwd, entryFile) {
66
+ const src = fs.readFileSync(path.join(cwd, entryFile), 'utf8');
67
+ let m = src.match(/\.listen\(\s*(\d{2,5})/);
68
+ if (m) return Number(m[1]);
69
+ m = src.match(/\.listen\(\s*(?:process\.env\.(\w+))/);
70
+ if (m) {
71
+ const envPath = path.join(cwd, '.env');
72
+ if (fs.existsSync(envPath)) {
73
+ const line = fs.readFileSync(envPath, 'utf8').split(/\r?\n/).find((l) => l.startsWith(`${m[1]}=`));
74
+ if (line) {
75
+ const v = Number(line.split('=')[1].trim());
76
+ if (v) return v;
77
+ }
78
+ }
79
+ }
80
+ // PORT env var or const PORT = 3000 pattern
81
+ m = src.match(/(?:PORT|port)\s*=\s*(?:process\.env\.\w+\s*(?:\|\||\?\?)\s*)?(\d{2,5})/);
82
+ if (m) return Number(m[1]);
83
+ return 3000; // default + warning emitted by caller
84
+ }
@@ -0,0 +1,185 @@
1
+ // generator/express.js — router generation + marked 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 { fileURLToPath } from 'node:url';
6
+ import { parse } from '@babel/parser';
7
+ import { toolNameFor } from '../parser/express.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
11
+ const MARK_END = '// <<< sparda-injection <<<';
12
+
13
+ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
14
+ const taken = new Set(['sparda_info', 'sparda_list_disabled_tools']);
15
+ const tools = {};
16
+ for (const r of routes) {
17
+ const name = toolNameFor(r, taken);
18
+ tools[name] = {
19
+ method: r.method.toUpperCase(),
20
+ path: r.path,
21
+ enabled: !r.mutating, // write-safety: mutating tools off by default
22
+ pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
23
+ description: r.description,
24
+ params: r.params,
25
+ confidence: r.confidence,
26
+ };
27
+ }
28
+
29
+ const localKey = crypto.randomUUID();
30
+ const entryAbs = path.resolve(cwd, entryFile);
31
+ const entryDir = path.dirname(entryAbs);
32
+ const ext = entryFile.endsWith('.ts') ? '.ts' : entryFile.endsWith('.mjs') ? '.mjs' : entryFile.endsWith('.cjs') ? '.cjs' : '.js';
33
+ const routerFileName = `sparda-router${ext}`;
34
+ const routerAbs = path.join(entryDir, routerFileName);
35
+
36
+ // --- render router from template
37
+ let tpl = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', 'express-router.txt'), 'utf8');
38
+ const isESM = moduleType === 'esm';
39
+ tpl = tpl
40
+ .replace('__IMPORT_LINE__', isESM ? "import express from 'express';" : "const express = require('express');")
41
+ .replace('__ROUTER_DECL__', isESM
42
+ ? 'export const spardaRouter = express.Router();'
43
+ : 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };')
44
+ .replace('__JSON_MW__', 'express.json()')
45
+ .replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
46
+ .replace('__LOCAL_KEY__', localKey)
47
+ .replace('__PORT__', String(port));
48
+ atomicWrite(routerAbs, tpl);
49
+
50
+ // --- inject into entry file (line-based via AST positions)
51
+ const injection = injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd });
52
+
53
+ // --- manifest
54
+ const manifest = {
55
+ version: 1,
56
+ framework: 'express',
57
+ entryFile,
58
+ moduleType,
59
+ port,
60
+ localKey,
61
+ generatedFiles: [path.relative(cwd, routerAbs).split(path.sep).join('/')],
62
+ injectedFiles: injection.injected ? [entryFile] : [],
63
+ createdAt: new Date().toISOString(),
64
+ tools: Object.fromEntries(Object.entries(tools).map(([k, v]) => [k, { method: v.method, path: v.path, enabled: v.enabled }])),
65
+ };
66
+ atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
67
+
68
+ ensureGitignore(cwd);
69
+ return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
70
+ }
71
+
72
+ function injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd }) {
73
+ const src = fs.readFileSync(entryAbs, 'utf8');
74
+ // backup first
75
+ const backupDir = path.join(cwd, '.sparda', 'backup');
76
+ fs.mkdirSync(backupDir, { recursive: true });
77
+ fs.writeFileSync(path.join(backupDir, `${path.basename(entryAbs)}.${Date.now()}`), src);
78
+
79
+ const importSpec = `./${routerFileName.replace(/\.(ts|js|mjs|cjs)$/, moduleType === 'esm' && routerFileName.endsWith('.js') ? '.js' : '')}`.replace(/\.$/, '');
80
+ const importLine = moduleType === 'esm'
81
+ ? `import { spardaRouter } from '${importSpec}';`
82
+ : `const { spardaRouter } = require('${importSpec}');`;
83
+
84
+ // strip previous injection blocks (idempotence)
85
+ let body = src;
86
+ const blockRx = new RegExp(`\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\n?`, 'g');
87
+ body = body.replace(blockRx, '\n');
88
+
89
+ let ast;
90
+ try {
91
+ ast = parse(body, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'], attachComment: false });
92
+ } catch {
93
+ return manualFallback(importLine);
94
+ }
95
+
96
+ const lines = body.split('\n');
97
+ let lastImportLine = 0;
98
+ let appAssignLine = null;
99
+ let listenLine = null;
100
+ let appName = 'app';
101
+
102
+ for (const node of ast.program.body) {
103
+ if (node.type === 'ImportDeclaration') lastImportLine = Math.max(lastImportLine, node.loc.end.line);
104
+ if (node.type === 'VariableDeclaration') {
105
+ for (const d of node.declarations) {
106
+ if (d.init?.type === 'CallExpression' && d.init.callee?.name === 'express') {
107
+ appAssignLine = node.loc.end.line;
108
+ appName = d.id.name;
109
+ }
110
+ if (d.init?.type === 'CallExpression' && d.init.callee?.name === 'require') {
111
+ lastImportLine = Math.max(lastImportLine, node.loc.end.line);
112
+ }
113
+ }
114
+ }
115
+ if (node.type === 'ExpressionStatement' &&
116
+ node.expression.type === 'CallExpression' &&
117
+ node.expression.callee?.property?.name === 'listen') {
118
+ listenLine = node.loc.start.line;
119
+ }
120
+ }
121
+
122
+ const useBlock = [MARK_START, importLine, `${appName}.use('/mcp', spardaRouter);`, MARK_END];
123
+
124
+ let insertAt; // 0-based index AFTER which we DON'T insert; we splice before index
125
+ if (appAssignLine !== null) insertAt = appAssignLine; // after app = express()
126
+ else if (listenLine !== null) insertAt = listenLine - 1; // before app.listen
127
+ else return manualFallback(importLine);
128
+
129
+ lines.splice(insertAt, 0, ...useBlock);
130
+ const out = lines.join('\n');
131
+
132
+ // post-injection re-parse safety check (blueprint pitfall #6)
133
+ try {
134
+ parse(out, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
135
+ } catch {
136
+ return manualFallback(importLine);
137
+ }
138
+ atomicWrite(entryAbs, out);
139
+ return { injected: true, manual: null };
140
+
141
+ function manualFallback(imp) {
142
+ return {
143
+ injected: false,
144
+ manual: [imp, `app.use('/mcp', spardaRouter);`],
145
+ };
146
+ }
147
+ }
148
+
149
+ export function removeInjection(cwd, manifest) {
150
+ const results = [];
151
+ for (const relFile of manifest.injectedFiles ?? []) {
152
+ const abs = path.resolve(cwd, relFile);
153
+ if (!fs.existsSync(abs)) continue;
154
+ const src = fs.readFileSync(abs, 'utf8');
155
+ const blockRx = new RegExp(`\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`, 'g');
156
+ const out = src.replace(blockRx, '');
157
+ try {
158
+ parse(out, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
159
+ atomicWrite(abs, out);
160
+ results.push({ file: relFile, ok: true });
161
+ } catch {
162
+ results.push({ file: relFile, ok: false });
163
+ }
164
+ }
165
+ return results;
166
+ }
167
+
168
+ function ensureGitignore(cwd) {
169
+ const gi = path.join(cwd, '.gitignore');
170
+ const line = '.sparda/';
171
+ if (fs.existsSync(gi)) {
172
+ 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`);
176
+ }
177
+ }
178
+
179
+ function atomicWrite(file, content) {
180
+ const tmp = `${file}.sparda-tmp`;
181
+ fs.writeFileSync(tmp, content);
182
+ fs.renameSync(tmp, file);
183
+ }
184
+
185
+ function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // SPARDA — Turn any codebase into an MCP server. Residual Labs.
3
+ const [, , cmd, ...rest] = process.argv;
4
+
5
+ const flags = new Set(rest.filter((a) => a.startsWith('--')));
6
+ const getOpt = (name, dflt) => {
7
+ const i = rest.indexOf(`--${name}`);
8
+ return i !== -1 && rest[i + 1] ? rest[i + 1] : dflt;
9
+ };
10
+
11
+ const opts = {
12
+ yes: flags.has('--yes') || flags.has('-y'),
13
+ verbose: flags.has('--verbose'),
14
+ port: getOpt('port', null),
15
+ cwd: process.cwd(),
16
+ };
17
+
18
+ try {
19
+ switch (cmd) {
20
+ case 'init': {
21
+ const { runInit } = await import('./commands/init.js');
22
+ await runInit(opts);
23
+ break;
24
+ }
25
+ case 'dev': {
26
+ const { runDev } = await import('./commands/dev.js');
27
+ await runDev(opts);
28
+ break;
29
+ }
30
+ case 'remove': {
31
+ const { runRemove } = await import('./commands/remove.js');
32
+ await runRemove(opts);
33
+ break;
34
+ }
35
+ case 'doctor': {
36
+ const { runDoctor } = await import('./commands/doctor.js');
37
+ await runDoctor(opts);
38
+ break;
39
+ }
40
+ default:
41
+ console.log(`SPARDA v0.1.0 — Turn any codebase into an MCP server.
42
+
43
+ Usage:
44
+ npx sparda-mcp init Scan project, generate & inject the MCP router
45
+ npx sparda-mcp dev Run the MCP stdio bridge (connect Claude Desktop)
46
+ npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
47
+ npx sparda-mcp doctor Diagnose your setup
48
+
49
+ Flags: --yes (skip prompts) --port <n> --verbose`);
50
+ process.exit(cmd ? 1 : 0);
51
+ }
52
+ } catch (err) {
53
+ console.error(`\n✗ ${err?.message ?? err}`);
54
+ if (err?.hint) console.error(` → ${err.hint}`);
55
+ if (opts.verbose && err?.stack) console.error(err.stack);
56
+ else console.error(' (run with --verbose for details — or open an issue: github.com/zyx77550/sparda/issues)');
57
+ process.exit(err?.code === 'USER' ? 1 : 2);
58
+ }
@@ -0,0 +1,182 @@
1
+ // parser/express.js — AST route extraction (spec: blueprint 03-PARSING §B)
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parse } from '@babel/parser';
5
+ import traverseModule from '@babel/traverse';
6
+ const traverse = traverseModule.default ?? traverseModule;
7
+
8
+ const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
9
+ const EXCLUDE = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.sparda']);
10
+
11
+ export function parseExpressProject(cwd, entryFile) {
12
+ const routes = [];
13
+ const skipped = [];
14
+ const visited = new Set();
15
+ const entryAbs = path.resolve(cwd, entryFile);
16
+
17
+ // routerName -> { file, prefix } discovered via app.use(prefix, ident)
18
+ const mounts = [];
19
+
20
+ parseFile(entryAbs, '', 0);
21
+ // second pass: parse mounted router files with their prefixes
22
+ for (const m of mounts) {
23
+ if (m.file && fs.existsSync(m.file)) parseFile(m.file, m.prefix, 1);
24
+ }
25
+
26
+ return { routes: dedupe(routes), skipped };
27
+
28
+ function parseFile(absFile, prefix, depth) {
29
+ if (depth > 2 || visited.has(absFile + '::' + prefix)) return;
30
+ visited.add(absFile + '::' + prefix);
31
+ let src;
32
+ try { src = fs.readFileSync(absFile, 'utf8'); } catch { return; }
33
+ let ast;
34
+ try {
35
+ ast = parse(src, {
36
+ sourceType: 'unambiguous',
37
+ plugins: ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: true }]],
38
+ attachComment: true,
39
+ });
40
+ } catch (e) {
41
+ skipped.push({ reason: `parse error in ${rel(absFile)}: ${e.message.slice(0, 80)}`, file: rel(absFile) });
42
+ return;
43
+ }
44
+
45
+ const appVars = new Set(); // vars = express()
46
+ const routerVars = new Set(); // vars = express.Router() / Router()
47
+ const importMap = new Map(); // localName -> absolute file path (relative imports only)
48
+ const exprStarts = new Set(); // line numbers where express() assigned (for injector)
49
+
50
+ traverse(ast, {
51
+ VariableDeclarator(p) {
52
+ const init = p.node.init;
53
+ const name = p.node.id?.name;
54
+ if (!name || !init) return;
55
+ if (init.type === 'CallExpression') {
56
+ 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
+ if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments[0]?.type === 'StringLiteral') {
61
+ const resolved = resolveRel(absFile, init.arguments[0].value);
62
+ if (resolved) importMap.set(name, resolved);
63
+ }
64
+ }
65
+ },
66
+ ImportDeclaration(p) {
67
+ const resolved = resolveRel(absFile, p.node.source.value);
68
+ if (!resolved) return;
69
+ for (const spec of p.node.specifiers) importMap.set(spec.local.name, resolved);
70
+ },
71
+ CallExpression(p) {
72
+ const callee = p.node.callee;
73
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier') return;
74
+ const objName = callee.object.type === 'Identifier' ? callee.object.name : null;
75
+ const method = callee.property.name;
76
+ const args = p.node.arguments;
77
+
78
+ // app.use(prefix, routerIdent) -> mount
79
+ if (method === 'use' && objName && appVars.has(objName) && args.length >= 2) {
80
+ const [a0, a1] = args;
81
+ if (a0.type === 'StringLiteral' && a1.type === 'Identifier') {
82
+ mounts.push({ prefix: joinPath(prefix, a0.value), file: importMap.get(a1.name) ?? null, ident: a1.name });
83
+ if (!importMap.get(a1.name)) skipped.push({ reason: `router "${a1.name}" mounted at ${a0.value} — source file not resolved`, file: rel(absFile), line: p.node.loc.start.line });
84
+ }
85
+ return;
86
+ }
87
+
88
+ if (!HTTP.has(method)) return;
89
+ const isApp = objName && appVars.has(objName);
90
+ const isRouter = objName && routerVars.has(objName);
91
+ if (!isApp && !isRouter) return;
92
+
93
+ const pathArg = args[0];
94
+ if (!pathArg || pathArg.type !== 'StringLiteral') {
95
+ skipped.push({ reason: `dynamic path on ${method.toUpperCase()} (non-literal first arg)`, file: rel(absFile), line: p.node.loc?.start.line });
96
+ return;
97
+ }
98
+
99
+ const fullPath = joinPath(prefix, pathArg.value);
100
+ const handler = args[args.length - 1];
101
+ const handlerName = handler?.type === 'Identifier' ? handler.name
102
+ : handler?.id?.name ?? `anonymous_${routes.length + 1}`;
103
+
104
+ const leading = (p.parentPath.node.leadingComments ?? p.node.leadingComments ?? [])
105
+ .map((c) => c.value.replace(/^\*+/gm, '').replace(/^\s*\*\s?/gm, '').trim())
106
+ .filter(Boolean).join(' ');
107
+
108
+ const pathParams = [...fullPath.matchAll(/:(\w+)/g)].map((mm) => ({
109
+ name: mm[1], in: 'path', type: 'string', required: true, description: 'path parameter',
110
+ }));
111
+
112
+ const mutating = method !== 'get';
113
+ const params = [...pathParams];
114
+ let confidence = 'high';
115
+ if (mutating) {
116
+ params.push({ name: 'body', in: 'body', type: 'object', required: false, description: 'JSON body — schema not statically detected' });
117
+ confidence = 'low';
118
+ }
119
+
120
+ routes.push({
121
+ method, path: fullPath, handlerName,
122
+ sourceFile: rel(absFile), sourceLine: p.node.loc?.start.line ?? 0,
123
+ params, description: leading.slice(0, 400), mutating, confidence,
124
+ });
125
+ },
126
+ });
127
+
128
+ // depth-1 import follow for files that themselves declare app routes (rare but covered)
129
+ if (depth === 0) {
130
+ for (const [, file] of importMap) {
131
+ if (!visited.has(file + '::' + prefix) && fs.existsSync(file)) {
132
+ // only follow if the file references Router or app routes — cheap pre-check
133
+ const s = fs.readFileSync(file, 'utf8');
134
+ if (/\bRouter\s*\(/.test(s)) continue; // handled by mounts pass with proper prefix
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ function rel(abs) { return path.relative(cwd, abs).split(path.sep).join('/'); }
141
+
142
+ function resolveRel(fromFile, spec) {
143
+ if (!spec.startsWith('.')) return null;
144
+ const base = path.resolve(path.dirname(fromFile), spec);
145
+ for (const cand of [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, `${base}.cjs`,
146
+ path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
147
+ if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
148
+ if ([...EXCLUDE].some((x) => cand.includes(`${path.sep}${x}${path.sep}`))) return null;
149
+ return cand;
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function joinPath(prefix, p) {
157
+ const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
158
+ return joined.startsWith('/') ? joined : `/${joined}`;
159
+ }
160
+
161
+ function dedupe(routes) {
162
+ const seen = new Set();
163
+ return routes.filter((r) => {
164
+ const k = `${r.method} ${r.path}`;
165
+ if (seen.has(k)) return false;
166
+ seen.add(k);
167
+ return true;
168
+ });
169
+ }
170
+
171
+ export function toolNameFor(route, taken) {
172
+ let name = `${route.method}_${route.path}`
173
+ .toLowerCase()
174
+ .replace(/:(\w+)/g, 'by_$1')
175
+ .replace(/[^a-z0-9]+/g, '_')
176
+ .replace(/^_+|_+$/g, '')
177
+ .slice(0, 60) || 'tool';
178
+ let final = name; let i = 2;
179
+ while (taken.has(final)) final = `${name}_${i++}`;
180
+ taken.add(final);
181
+ return final;
182
+ }
@@ -0,0 +1,16 @@
1
+ // security/sanitize.js — docstring poisoning defense (spec: blueprint 06-SECURITY §2)
2
+ const DANGEROUS = [
3
+ /(^|\s)(ignore|forget|disregard|override)\s+(all\s+)?(previous|prior|above|earlier)/i,
4
+ /(you\s+are\s+(now|a)\b|act\s+as\b|pretend\s+(you\s+are|to\s+be)|from\s+now\s+on)/i,
5
+ /(system\s+prompt|env(ironment)?\s+var|api[\s_-]?key|credential|secret|exfiltrat|\.env\b)/i,
6
+ /(<\/?(system|assistant|human|tool|instructions?)\b|```)/i,
7
+ /(new\s+(role|instructions?)|your\s+(real|true)\s+(task|goal))/i,
8
+ ];
9
+
10
+ export function sanitizeDescription(raw, fallback) {
11
+ let text = String(raw ?? '').replace(/[\r\n]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, 300);
12
+ const flagged = DANGEROUS.some((rx) => rx.test(text));
13
+ if (flagged) text = '';
14
+ text = text.replace(/[<>{}]/g, '');
15
+ return { text: text || fallback, flagged };
16
+ }
@@ -0,0 +1,115 @@
1
+ // server/stdio.js — MCP stdio bridge (spec: blueprint 05-MCP-SERVER)
2
+ // CRITICAL: stdout is the MCP protocol. All human logs -> stderr. (pitfall #1)
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+
9
+ export async function startStdioBridge({ cwd, portOverride }) {
10
+ // pitfall #1: neutralize any stray console.log from deps
11
+ console.log = (...a) => console.error(...a);
12
+
13
+ const manifestPath = path.join(cwd, 'sparda.json');
14
+ if (!fs.existsSync(manifestPath)) {
15
+ throw Object.assign(new Error('sparda.json not found.'), { code: 'USER', hint: 'Run `npx sparda-mcp init` first.' });
16
+ }
17
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
18
+ const port = Number(portOverride ?? manifest.port);
19
+ const key = manifest.localKey;
20
+ const base = await waitForHost(port, key);
21
+
22
+ // full tool specs live in the generated router; fetch them (single source of truth)
23
+ const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers: { 'x-sparda-key': key } })).json();
24
+
25
+ const enabled = Object.entries(toolSpecs).filter(([, t]) => t.enabled);
26
+ const disabled = Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
27
+
28
+ const server = new Server(
29
+ { name: `sparda-${path.basename(cwd)}`, version: '0.1.0' },
30
+ { capabilities: { tools: {} } },
31
+ );
32
+
33
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
34
+ tools: [
35
+ ...enabled.map(([name, t]) => ({
36
+ name,
37
+ description: `${t.confidence === 'low' ? '[partial schema] ' : ''}${t.description || `${t.method} ${t.path}`}`,
38
+ inputSchema: schemaFor(t),
39
+ })),
40
+ {
41
+ 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',
43
+ inputSchema: { type: 'object', properties: {} },
44
+ },
45
+ {
46
+ name: 'sparda_list_disabled_tools',
47
+ description: 'Lists write tools (POST/PUT/DELETE) disabled by SPARDA write-safety, and how to enable them.',
48
+ inputSchema: { type: 'object', properties: {} },
49
+ },
50
+ ],
51
+ }));
52
+
53
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
54
+ const { name, arguments: args = {} } = req.params;
55
+
56
+ if (name === 'sparda_info') {
57
+ return text(JSON.stringify({
58
+ project: path.basename(cwd), framework: manifest.framework,
59
+ tools_enabled: enabled.length, tools_disabled_write_safety: disabled.length,
60
+ generated_by: 'SPARDA — npx sparda-mcp init — github.com/zyx77550/sparda',
61
+ }, null, 2));
62
+ }
63
+ 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.`
66
+ : 'No disabled tools.');
67
+ }
68
+
69
+ try {
70
+ const res = await fetch(`${base}/mcp/invoke`, {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json', 'x-sparda-key': key },
73
+ body: JSON.stringify({ tool: name, args }),
74
+ signal: AbortSignal.timeout(30_000),
75
+ });
76
+ const payload = await res.json().catch(() => ({ error: 'non-JSON response from host' }));
77
+ const pretty = JSON.stringify(payload, null, 2);
78
+ const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty;
79
+ return { content: [{ type: 'text', text: truncated }], isError: res.status >= 400 };
80
+ } catch (err) {
81
+ return { content: [{ type: 'text', text: `Host app error: ${err.message}. Check that your server is still running on :${port}.` }], isError: true };
82
+ }
83
+ });
84
+
85
+ const transport = new StdioServerTransport();
86
+ await server.connect(transport);
87
+ console.error(`[sparda] MCP bridge running. ${enabled.length} tools enabled, ${disabled.length} disabled (write-safety). Host: ${base}`);
88
+ }
89
+
90
+ function schemaFor(t) {
91
+ const properties = {}; const required = [];
92
+ for (const p of t.params ?? []) {
93
+ properties[p.name] = { type: p.type === 'unknown' ? 'string' : p.type, description: p.description ?? p.in };
94
+ if (p.required) required.push(p.name);
95
+ }
96
+ return { type: 'object', properties, ...(required.length ? { required } : {}) };
97
+ }
98
+
99
+ function text(t) { return { content: [{ type: 'text', text: t }] }; }
100
+
101
+ async function waitForHost(port, key) {
102
+ const hosts = ['127.0.0.1', 'localhost'];
103
+ for (let attempt = 0; attempt < 40; attempt++) {
104
+ for (const h of hosts) {
105
+ try {
106
+ const r = await fetch(`http://${h}:${port}/mcp/tools`, { headers: { 'x-sparda-key': key }, signal: AbortSignal.timeout(1500) });
107
+ if (r.ok) return `http://${h}:${port}`;
108
+ if (r.status === 401) throw Object.assign(new Error('Host app reachable but key mismatch — re-run `npx sparda-mcp init`.'), { code: 'USER' });
109
+ } catch (e) { if (e.code === 'USER') throw e; }
110
+ }
111
+ if (attempt === 0) console.error(`[sparda] Waiting for host app on :${port} ... (start it with npm run dev — Ctrl+C to abort)`);
112
+ await new Promise((r) => setTimeout(r, 3000));
113
+ }
114
+ 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`.' });
115
+ }
@@ -0,0 +1,58 @@
1
+ // ============================================================
2
+ // SPARDA ROUTER — Auto-generated. DO NOT EDIT.
3
+ // Regenerate: npx sparda-mcp init • Remove: npx sparda-mcp remove
4
+ // ============================================================
5
+ __IMPORT_LINE__
6
+
7
+ const SPARDA_TOOLS = __TOOLS_JSON__;
8
+
9
+ const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
10
+ const SPARDA_PORT = __PORT__;
11
+
12
+ __ROUTER_DECL__
13
+
14
+ spardaRouter.use((req, res, next) => {
15
+ if (req.headers['x-sparda-key'] !== SPARDA_LOCAL_KEY) {
16
+ return res.status(401).json({ error: 'unauthorized' });
17
+ }
18
+ next();
19
+ });
20
+
21
+ spardaRouter.get('/tools', (_req, res) => res.json(SPARDA_TOOLS));
22
+
23
+ spardaRouter.post('/invoke', __JSON_MW__, async (req, res) => {
24
+ const { tool, args = {} } = req.body ?? {};
25
+ const spec = SPARDA_TOOLS[tool];
26
+ if (!spec) return res.status(404).json({ error: `unknown tool: ${tool}` });
27
+ if (!spec.enabled) {
28
+ return res.status(403).json({ error: `tool disabled (write-safety): ${tool}`, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init' });
29
+ }
30
+ if (spec.path.startsWith('/mcp')) {
31
+ return res.status(400).json({ error: 'self-referential tool blocked (loop protection)' });
32
+ }
33
+ try {
34
+ let url = spec.path.replace(/:(\w+)/g, (_, name) => {
35
+ const v = args[name];
36
+ if (v === undefined) throw Object.assign(new Error(`missing path param: ${name}`), { status: 400 });
37
+ return encodeURIComponent(String(v));
38
+ });
39
+ const query = [];
40
+ for (const [k, v] of Object.entries(args)) {
41
+ if (k === 'body' || spec.pathParams.includes(k) || v === undefined) continue;
42
+ query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
43
+ }
44
+ if (query.length) url += `?${query.join('&')}`;
45
+
46
+ const init = { method: spec.method, headers: {} };
47
+ if (spec.method !== 'GET' && args.body !== undefined) {
48
+ init.headers['content-type'] = 'application/json';
49
+ init.body = JSON.stringify(args.body);
50
+ }
51
+ const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
52
+ const text = await upstream.text();
53
+ let data; try { data = JSON.parse(text); } catch { data = text; }
54
+ return res.status(200).json({ upstreamStatus: upstream.status, data });
55
+ } catch (err) {
56
+ return res.status(err.status ?? 502).json({ error: err.message });
57
+ }
58
+ });