sparda-mcp 0.8.1 → 0.9.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 CHANGED
@@ -6,23 +6,27 @@
6
6
 
7
7
  <br/>
8
8
 
9
- **Your AI can write code. It still can't operate your app.**
9
+ **Compile your backend into a deterministic behavior runtime.**
10
10
 
11
- Claude, Cursor & friends read your *files* not your *running product*. They can
12
- refactor a controller, but they can't create an order, fetch a real user, or see why
13
- production is failing. And giving an AI real access to your API usually means: write
14
- an OpenAPI spec, build an MCP server, host it, secure it, keep it in sync with every
15
- route change — and pray it never `DELETE`s the wrong row. Days of glue code, per
16
- project, forever drifting.
11
+ For twenty years, software communicated via APIs. Then AI agents arrived. The industry's response was simply to expose more endpoints (MCP). But an agent doesn't understand your application; it only sees a list of disconnected tools. That is enough for a human, but not for an autonomous machine.
17
12
 
18
- SPARDA deletes that work:
13
+ SPARDA analyzes your Express, FastAPI, or Next.js application, extracts its behavior, and compiles it into a **SPARDA Behavior Graph (SBG)**.
14
+
15
+ This graph acts as a local, deterministic runtime that powers:
16
+ * **MCP Server Integration**: Exposing your endpoints to AI clients dynamically.
17
+ * **SPARDA Twin**: An executable, offline simulation clone of your backend for safe agent testing.
18
+ * **SPARDA Immune**: Real-time graph-based anomaly defense, latency alerts, and auto-quarantine.
19
+ * **SPARDA Evolution**: Statically inferred grammar mapping and in-memory workflow optimization.
20
+
21
+ All executed 100% locally, with zero runtime dependencies and zero cloud connection.
19
22
 
20
23
  ```bash
21
- npx sparda-mcp init # scan your Express/FastAPI app, inject the MCP router 3 minutes
22
- npx sparda-mcp dev # connect Claude Desktop / Claude Code. Done.
24
+ npx sparda-mcp init # Scan your app, compile the UBG, inject the SPARDA Runtime
25
+ npx sparda-mcp dev # Connect Claude Desktop / Cursor over stdio
23
26
  ```
24
27
 
25
- No OpenAPI spec. No account. No API key. No server to host.
28
+ No OpenAPI spec to write. No cloud account. No API key. No server to host.
29
+ Exposing raw APIs to AI is the old way. SPARDA compiles the whole system's behavior.
26
30
 
27
31
  ## Quickstart
28
32
 
@@ -169,6 +173,7 @@ runtime, so the guidance never goes stale.
169
173
 
170
174
  ## Security posture (honest)
171
175
  - 4 runtime dependencies, exact-pinned.
176
+ - **Dynamic Local Key Resolution.** The generated router contains no baked secrets. It resolves authorization keys at runtime from the `SPARDA_LOCAL_KEY` environment variable or the local gitignored `.sparda/key` file, and fails closed (503) when neither is found. For custom production or staging setups, you can override this behavior by exposing `SPARDA_LOCAL_KEY` in your environment.
172
177
  - Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
173
178
  - AST-positioned injection with backup and post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
174
179
  - Persistence is **value-free**: SPARDA records structure (tool names, field names, fingerprints), never your payloads.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
6
6
  "type": "module",
@@ -0,0 +1,71 @@
1
+ // commands/ubg.js — compile the codebase to its Unified Behavior Graph.
2
+ // The UBG is the IR every future SPARDA tool reads (time-travel debuggers,
3
+ // deploy provers, state-machine runtimes): compile once here, write passes
4
+ // there. Artifact: .sparda/ubg.json — regenerable, deterministic, never
5
+ // committed. `--json` prints the raw graph, `--out <file>` redirects it.
6
+ import { compileUBG } from '../ubg/compile.js';
7
+
8
+ export async function runUbg(opts) {
9
+ const { report, json, outPath } = compileUBG(opts.cwd, {
10
+ write: true,
11
+ out: opts.out,
12
+ });
13
+
14
+ if (opts.json) {
15
+ console.log(json);
16
+ return { report };
17
+ }
18
+
19
+ const { counts } = report;
20
+ console.log(
21
+ `✓ UBG compiled: ${rel(outPath, opts.cwd)} — ${counts.totalNodes} nodes, ${counts.totalEdges} edges`,
22
+ );
23
+ console.log(
24
+ ` ${report.framework} · ${report.routes} routes · ${report.tables} SQL tables`,
25
+ );
26
+ console.log(` Nodes: ${fmtCounts(counts.nodes)}`);
27
+ console.log(` Edges: ${fmtCounts(counts.edges)}`);
28
+
29
+ for (const p of report.passes) {
30
+ if (p.pass === 'DeadPathElimination' && p.removed)
31
+ console.log(` ${p.pass}: ${p.removed} dead node(s) removed`);
32
+ else if (p.pass === 'StateMinimization' && p.merged)
33
+ console.log(` ${p.pass}: ${p.merged} logic block(s) merged`);
34
+ else if (p.pass === 'TypePropagation' && (p.resolved || p.entrypointsTyped))
35
+ console.log(
36
+ ` ${p.pass}: ${p.entrypointsTyped} entrypoint return schema(s), ${p.resolved} type(s) resolved`,
37
+ );
38
+ else if (p.pass === 'EffectAlgebra' && p.classified)
39
+ console.log(
40
+ ` ${p.pass}: ${p.classified} effect(s) classified, ${p.observable} observable`,
41
+ );
42
+ else if (p.pass === 'ConsistencyDomains' && Object.keys(p.domains ?? {}).length) {
43
+ const names = Object.keys(p.domains).sort();
44
+ console.log(
45
+ ` ${p.pass}: ${names.length} domain(s) — ${names.join(', ')}${p.cycles.length ? ` (⚠ FK cycle: ${p.cycles.join(', ')})` : ''}`,
46
+ );
47
+ }
48
+ }
49
+
50
+ if (report.link.inferredTables.length)
51
+ console.log(
52
+ ` ⚠ tables referenced in code but absent from .sql schemas: ${report.link.inferredTables.join(', ')}`,
53
+ );
54
+ if (report.skipped.length) {
55
+ console.log(` · ${report.skipped.length} construct(s) out of static reach:`);
56
+ for (const s of report.skipped.slice(0, opts.verbose ? 100 : 5))
57
+ console.log(` - ${s.reason}${s.file ? ` (${s.file})` : ''}`);
58
+ if (!opts.verbose && report.skipped.length > 5)
59
+ console.log(` … ${report.skipped.length - 5} more (--verbose)`);
60
+ }
61
+ return { report };
62
+ }
63
+
64
+ const fmtCounts = (obj) =>
65
+ Object.entries(obj)
66
+ .sort(([a], [b]) => a.localeCompare(b))
67
+ .map(([k, v]) => `${k} ${v}`)
68
+ .join(' · ');
69
+
70
+ const rel = (abs, cwd) =>
71
+ abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
package/src/index.js CHANGED
@@ -94,6 +94,11 @@ try {
94
94
  await runEvolve(opts);
95
95
  break;
96
96
  }
97
+ case 'ubg': {
98
+ const { runUbg } = await import('./commands/ubg.js');
99
+ await runUbg(opts);
100
+ break;
101
+ }
97
102
  default:
98
103
  console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
99
104
 
@@ -110,6 +115,7 @@ Usage:
110
115
  npx sparda-mcp twin The living mock (--learn from the live app, then serve the ghost)
111
116
  npx sparda-mcp grammar Which call sequences mean something (observed + hypotheses)
112
117
  npx sparda-mcp evolve Trial hypothesis chains against the twin; survivors become suggestions
118
+ npx sparda-mcp ubg Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json)
113
119
 
114
120
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
115
121
  --probe (init: also run the app to discover dynamic routes the AST missed)
@@ -0,0 +1,84 @@
1
+ // ubg/compile.js — the orchestrator: codebase in, UBG out.
2
+ // detect → extract (framework routes + SQL schemas) → translate → link →
3
+ // optimize → canonicalize. 100% local, zero network, zero LLM, deterministic:
4
+ // the only inputs are the bytes of the source tree, the only output is the
5
+ // graph plus an honest report of everything the static eye could NOT see.
6
+ import { detectStack } from '../detect.js';
7
+ import { clearModuleCache } from './extract.js';
8
+ import { extractExpress } from './express.js';
9
+ import { extractNext } from './nextjs.js';
10
+ import { extractFastAPI } from './fastapi.js';
11
+ import { parseSqlSchemas } from './sql.js';
12
+ import { translate } from './translate.js';
13
+ import { linkDataFlow } from './link.js';
14
+ import { optimize } from './pipeline.js';
15
+ import { validateGraph } from './schema.js';
16
+ import { serializeGraph, sourceHashOf, writeGraph } from './serialize.js';
17
+
18
+ export function compileUBG(
19
+ cwd,
20
+ { write = true, out = null, optimizePasses = true } = {},
21
+ ) {
22
+ clearModuleCache(); // each compile run parses fresh — no stale-file ghosts
23
+
24
+ const stack = detectStack(cwd);
25
+ const extractors = {
26
+ express: () => extractExpress(cwd, stack.entryFile),
27
+ nextjs: () => extractNext(cwd, stack.entryFile),
28
+ fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
29
+ };
30
+ if (!extractors[stack.framework]) {
31
+ throw Object.assign(
32
+ new Error(`UBG compiler has no lowering for ${stack.framework} yet.`),
33
+ { code: 'USER' },
34
+ );
35
+ }
36
+ const extracted = extractors[stack.framework]();
37
+
38
+ const sql = parseSqlSchemas(cwd);
39
+
40
+ const graph = translate({
41
+ framework: stack.framework,
42
+ routes: extracted.routes,
43
+ globalMiddlewares: extracted.globalMiddlewares,
44
+ helpers: extracted.helpers,
45
+ tables: sql.tables,
46
+ });
47
+ validateGraph(graph);
48
+
49
+ const linkReport = linkDataFlow(graph);
50
+ validateGraph(graph);
51
+
52
+ const passReports = optimizePasses ? optimize(graph) : [];
53
+
54
+ graph.meta = {
55
+ framework: stack.framework,
56
+ entry: stack.entryFile,
57
+ sourceHash: sourceHashOf(cwd, [
58
+ ...extracted.scannedFiles,
59
+ ...sql.tables.map((t) => t.sourceFile),
60
+ ]),
61
+ };
62
+
63
+ const report = {
64
+ framework: stack.framework,
65
+ entry: stack.entryFile,
66
+ routes: extracted.routes.length,
67
+ tables: sql.tables.length,
68
+ link: linkReport,
69
+ passes: passReports,
70
+ skipped: [...extracted.skipped, ...sql.skipped],
71
+ counts: countGraph(graph),
72
+ };
73
+
74
+ const outPath = write ? writeGraph(graph, cwd, out) : null;
75
+ return { graph, json: serializeGraph(graph), report, outPath };
76
+ }
77
+
78
+ function countGraph(graph) {
79
+ const nodes = {};
80
+ const edges = {};
81
+ for (const n of graph.nodes.values()) nodes[n.kind] = (nodes[n.kind] ?? 0) + 1;
82
+ for (const e of graph.edges) edges[e.kind] = (edges[e.kind] ?? 0) + 1;
83
+ return { nodes, edges, totalNodes: graph.nodes.size, totalEdges: graph.edges.length };
84
+ }
@@ -0,0 +1,258 @@
1
+ // ubg/express.js — Express codebase → route facts for the UBG translator.
2
+ // Richer than parser/express.js (which only needs tool signatures): here the
3
+ // FULL handler chain matters — every middleware between path and handler
4
+ // becomes a graph node, and identifier handlers are resolved to their function
5
+ // bodies (same file, then relative imports) so the microscope can scan them.
6
+ // Depth stays bounded like the v0 parser: entry file + mounted routers.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseModule, resolveRelImport } from './extract.js';
10
+
11
+ const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
12
+
13
+ // → { routes, globalMiddlewares, helpers, skipped, scannedFiles }
14
+ export function extractExpress(cwd, entryFile) {
15
+ const routes = [];
16
+ const globalMiddlewares = []; // app.use(fn) — applies to every route
17
+ const helpers = []; // top-level functions of scanned files (dead-path candidates)
18
+ const skipped = [];
19
+ const scannedFiles = [];
20
+ const visited = new Set();
21
+ const mounts = [];
22
+
23
+ scanFile(path.resolve(cwd, entryFile), '', 0);
24
+ for (const m of [...mounts]) {
25
+ if (m.file && fs.existsSync(m.file)) scanFile(m.file, m.prefix, 1);
26
+ else if (!m.file)
27
+ skipped.push({
28
+ reason: `router "${m.ident}" mounted at ${m.prefix} — source file not resolved`,
29
+ file: m.fromFile,
30
+ });
31
+ }
32
+
33
+ routes.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
34
+ return { routes, globalMiddlewares, helpers, skipped, scannedFiles };
35
+
36
+ function scanFile(absFile, prefix, depth) {
37
+ const key = `${absFile}::${prefix}`;
38
+ if (depth > 2 || visited.has(key)) return;
39
+ visited.add(key);
40
+
41
+ const mod = parseModule(absFile);
42
+ const relFile = rel(absFile);
43
+ if (mod.error) {
44
+ skipped.push({ reason: `${mod.error} in ${relFile}`, file: relFile });
45
+ return;
46
+ }
47
+ scannedFiles.push(relFile);
48
+
49
+ // every top-level function is a potential dead-path candidate
50
+ for (const [name, f] of mod.functions) {
51
+ helpers.push({ name, sourceFile: relFile, sourceLine: f.line, fn: f.node });
52
+ }
53
+
54
+ const appVars = new Set();
55
+ const routerVars = new Set();
56
+ for (const node of mod.ast.program.body) collectAppVars(node, appVars, routerVars);
57
+
58
+ walkStatements(mod.ast.program.body);
59
+
60
+ function walkStatements(body) {
61
+ for (const stmt of body) {
62
+ const expr =
63
+ stmt.type === 'ExpressionStatement' && stmt.expression.type === 'CallExpression'
64
+ ? stmt.expression
65
+ : null;
66
+ if (!expr) continue;
67
+ const callee = expr.callee;
68
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
69
+ continue;
70
+ const objName = callee.object.type === 'Identifier' ? callee.object.name : null;
71
+ if (!objName || (!appVars.has(objName) && !routerVars.has(objName))) continue;
72
+ const method = callee.property.name;
73
+ const args = expr.arguments;
74
+
75
+ if (method === 'use') {
76
+ handleUse(args, stmt);
77
+ continue;
78
+ }
79
+ if (!HTTP.has(method)) continue;
80
+
81
+ const pathArg = args[0];
82
+ if (!pathArg || pathArg.type !== 'StringLiteral') {
83
+ skipped.push({
84
+ reason: `dynamic path on ${method.toUpperCase()} (non-literal first arg)`,
85
+ file: relFile,
86
+ line: expr.loc?.start.line,
87
+ });
88
+ continue;
89
+ }
90
+ const fullPath = joinPath(prefix, pathArg.value);
91
+
92
+ const chain = [];
93
+ for (let i = 1; i < args.length; i++) {
94
+ const resolved = resolveCallable(args[i], mod, absFile, relFile);
95
+ if (!resolved) {
96
+ skipped.push({
97
+ reason: `unresolvable handler arg #${i} on ${method.toUpperCase()} ${fullPath}`,
98
+ file: relFile,
99
+ line: args[i].loc?.start.line,
100
+ });
101
+ continue;
102
+ }
103
+ resolved.role = i === args.length - 1 ? 'handler' : 'middleware';
104
+ chain.push(resolved);
105
+ }
106
+
107
+ const description = (stmt.leadingComments ?? expr.leadingComments ?? [])
108
+ .map((c) =>
109
+ c.value
110
+ .replace(/^\*+/gm, '')
111
+ .replace(/^\s*\*\s?/gm, '')
112
+ .trim(),
113
+ )
114
+ .filter(Boolean)
115
+ .join(' ')
116
+ .slice(0, 400);
117
+
118
+ routes.push({
119
+ method,
120
+ path: fullPath,
121
+ sourceFile: relFile,
122
+ sourceLine: expr.loc?.start.line ?? 0,
123
+ params: pathParamsOf(fullPath),
124
+ chain,
125
+ description,
126
+ });
127
+ }
128
+ }
129
+
130
+ function handleUse(args, stmt) {
131
+ if (!args.length) return;
132
+ // app.use('/prefix', router) → mount for second pass
133
+ if (args[0].type === 'StringLiteral' && args[1]?.type === 'Identifier') {
134
+ mounts.push({
135
+ prefix: joinPath(prefix, args[0].value),
136
+ file: mod.imports.get(args[1].name) ?? null,
137
+ ident: args[1].name,
138
+ fromFile: relFile,
139
+ });
140
+ return;
141
+ }
142
+ // app.use(fn) / app.use(ident) at depth 0 → global middleware
143
+ if (depth === 0) {
144
+ for (const a of args) {
145
+ if (a.type === 'StringLiteral') continue;
146
+ // express.json() & friends: framework plumbing, not app behavior
147
+ if (
148
+ a.type === 'CallExpression' &&
149
+ ((a.callee.type === 'MemberExpression' &&
150
+ a.callee.object.type === 'Identifier' &&
151
+ a.callee.object.name === 'express') ||
152
+ (a.callee.type === 'Identifier' &&
153
+ /^(cors|helmet|morgan|compression|cookieparser|bodyparser)$/i.test(
154
+ a.callee.name,
155
+ )))
156
+ )
157
+ continue;
158
+ const resolved = resolveCallable(a, mod, absFile, relFile);
159
+ if (resolved) {
160
+ resolved.role = 'middleware';
161
+ globalMiddlewares.push(resolved);
162
+ } else if (a.type !== 'CallExpression') {
163
+ skipped.push({
164
+ reason: `unresolvable app.use() argument`,
165
+ file: relFile,
166
+ line: stmt.loc?.start.line,
167
+ });
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ // Identifier → function body in this module, else in the module it was
175
+ // imported from. Inline functions pass through as-is.
176
+ function resolveCallable(arg, mod, absFile, relFile) {
177
+ if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
178
+ return {
179
+ name: arg.id?.name ?? 'anonymous',
180
+ sourceFile: relFile,
181
+ sourceLine: arg.loc?.start.line ?? 0,
182
+ fn: arg,
183
+ };
184
+ }
185
+ if (arg.type !== 'Identifier') return null;
186
+
187
+ const local = mod.functions.get(arg.name);
188
+ if (local) {
189
+ return {
190
+ name: arg.name,
191
+ sourceFile: relFile,
192
+ sourceLine: local.line,
193
+ fn: local.node,
194
+ };
195
+ }
196
+ const importedFrom = mod.imports.get(arg.name);
197
+ if (importedFrom) {
198
+ const target = parseModule(importedFrom);
199
+ const fn = target.functions.get(arg.name) ?? target.functions.get('default');
200
+ if (fn) {
201
+ const fromRel = rel(importedFrom);
202
+ if (!scannedFiles.includes(fromRel)) {
203
+ scannedFiles.push(fromRel);
204
+ for (const [name, f] of target.functions)
205
+ helpers.push({
206
+ name,
207
+ sourceFile: fromRel,
208
+ sourceLine: f.line,
209
+ fn: f.node,
210
+ });
211
+ }
212
+ return { name: arg.name, sourceFile: fromRel, sourceLine: fn.line, fn: fn.node };
213
+ }
214
+ }
215
+ // known but bodyless — node exists, microscope stays blind
216
+ return {
217
+ name: arg.name,
218
+ sourceFile: relFile,
219
+ sourceLine: arg.loc?.start.line ?? 0,
220
+ fn: null,
221
+ };
222
+ }
223
+
224
+ function rel(abs) {
225
+ return path.relative(cwd, abs).split(path.sep).join('/');
226
+ }
227
+ }
228
+
229
+ function collectAppVars(node, appVars, routerVars) {
230
+ if (node.type === 'ExportNamedDeclaration' && node.declaration)
231
+ return collectAppVars(node.declaration, appVars, routerVars);
232
+ if (node.type !== 'VariableDeclaration') return;
233
+ for (const d of node.declarations) {
234
+ if (d.id?.type !== 'Identifier' || d.init?.type !== 'CallExpression') continue;
235
+ const callee = d.init.callee;
236
+ if (callee.type === 'Identifier' && callee.name === 'express') appVars.add(d.id.name);
237
+ if (callee.type === 'Identifier' && callee.name === 'Router')
238
+ routerVars.add(d.id.name);
239
+ if (callee.type === 'MemberExpression' && callee.property?.name === 'Router')
240
+ routerVars.add(d.id.name);
241
+ }
242
+ }
243
+
244
+ function pathParamsOf(fullPath) {
245
+ return [...fullPath.matchAll(/:(\w+)/g)].map((m) => ({
246
+ name: m[1],
247
+ in: 'path',
248
+ type: 'string',
249
+ required: true,
250
+ }));
251
+ }
252
+
253
+ function joinPath(prefix, p) {
254
+ const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
255
+ return joined.startsWith('/') ? joined : `/${joined}`;
256
+ }
257
+
258
+ export { resolveRelImport };