sparda-mcp 0.3.0 → 0.5.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
@@ -45,6 +45,12 @@ Your app learns what "self" looks like — and defends itself:
45
45
  - **Immune memory.** Diagnoses are cached as antibodies in `sparda.json` (git-versioned): the same failure later is diagnosed instantly, zero tokens. Your app accumulates antibodies as it lives — cloning the code doesn't clone its immune system.
46
46
  - **`sparda_get_context`.** One tool call gives the AI the full living context: tools, workflows, runtime telemetry, quarantine state, immune memory. Every AI session resumes where the last one stopped.
47
47
 
48
+ ## v0.4 — the recycling economy + first Labs organ
49
+ - **Recycling gauge (zero config).** The router counts calls answered from SPARDA's own knowledge vs calls that paid the host route (`GET /mcp/stats → recycle`), and the bridge counts the sampling calls its cached knowledge avoided. Day 1 it reads 0% — the circle fills with usage. A measure, never a promise.
50
+ - **Idle harvester.** All of SPARDA's internal work (analysis, persistence) runs only when the event loop is quiet, one job per tick. Perceived saturation: zero.
51
+ - **Sequence condenser (Labs, opt-in, default OFF).** Set `"labs": { "recordSequences": true }` in `sparda.json` (or `SPARDA_RECORD_SEQUENCES=1` for one session) and SPARDA observes when one tool's output feeds the next tool's input, recording the *circuit* — structure only (tool names, arg names, counts), never your data. Deterministic, zero LLM.
52
+ - **Crystallization: tools nobody wrote.** A read-only circuit observed 3× is born as a *composite tool*, announced mid-session via `tools/list_changed`: one call runs the whole chain, auto-feeding each linked argument from the previous step's real response. Your AI names it (MCP sampling, sanitized) or a deterministic name ships without any LLM. Write routes are never absorbed — their per-call confirmation stands.
53
+
48
54
  Where this is going: [ROADMAP.md](./ROADMAP.md).
49
55
 
50
56
  ## v0 supports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,3 +27,25 @@ export async function runHook(opts) {
27
27
  fs.chmodSync(hookPath, 0o755);
28
28
  console.error('[sparda] sentinel installed: routes re-sync after every commit (post-commit hook).');
29
29
  }
30
+
31
+ // Uninstall exactly what runHook installed (hard rule #4 applied to .git/hooks):
32
+ // delete the file if we created it whole, strip our exact block if we appended,
33
+ // line-filter as a last resort if the user edited around it. Returns whether
34
+ // anything was removed so `remove` can report it.
35
+ export function removeHook(cwd) {
36
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-commit');
37
+ let content;
38
+ try { content = fs.readFileSync(hookPath, 'utf8'); } catch { return false; }
39
+ if (!content.includes(MARKER)) return false;
40
+ const block = `${MARKER}\nnpx --no-install sparda-mcp sync --quiet || true\n`;
41
+ if (content === `#!/bin/sh\n${block}`) {
42
+ fs.rmSync(hookPath);
43
+ return true;
44
+ }
45
+ let out = content.includes(`\n${block}`) ? content.replace(`\n${block}`, '') : content.replace(block, '');
46
+ if (out.includes(MARKER)) {
47
+ out = out.split('\n').filter((l) => l !== MARKER && l !== 'npx --no-install sparda-mcp sync --quiet || true').join('\n');
48
+ }
49
+ fs.writeFileSync(hookPath, out);
50
+ return true;
51
+ }
@@ -8,13 +8,14 @@ import { parseFastAPIProject } from '../parser/fastapi.js';
8
8
  import { sanitizeDescription } from '../security/sanitize.js';
9
9
  import { generateExpress } from '../generator/express.js';
10
10
  import { generateFastAPI } from '../generator/fastapi.js';
11
+ import { c, gradient, colorizeJson } from '../ui/style.js';
11
12
 
12
13
 
13
14
  const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
14
15
 
15
16
  export async function runInit(opts) {
16
17
  const t0 = Date.now();
17
- p.intro(`SPARDA v${VERSION}`);
18
+ p.intro(`${gradient('SPARDA')} ${c.dim(`v${VERSION}`)}`);
18
19
 
19
20
  const s = p.spinner();
20
21
  s.start('Scanning project...');
@@ -25,13 +26,13 @@ export async function runInit(opts) {
25
26
  const res = parseExpressProject(opts.cwd, stack.entryFile);
26
27
  routes = res.routes;
27
28
  skipped = res.skipped;
28
- s.stop(`Stack detected: Express (${stack.moduleType.toUpperCase()}) — entry: ${stack.entryFile}, port: ${stack.port}`);
29
+ s.stop(`Stack detected: ${c.cyan(`Express (${stack.moduleType.toUpperCase()})`)} — entry: ${stack.entryFile}, port: ${stack.port}`);
29
30
  } else if (stack.framework === 'fastapi') {
30
31
  const res = parseFastAPIProject(opts.cwd, stack.entryFile, stack.pythonCmd);
31
32
  routes = res.routes;
32
33
  skipped = res.skipped;
33
34
  entryAppVars = res.entryAppVars;
34
- s.stop(`Stack detected: FastAPI — entry: ${stack.entryFile}, port: ${stack.port}`);
35
+ s.stop(`Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`);
35
36
  }
36
37
 
37
38
  if (routes.length === 0) {
@@ -49,9 +50,9 @@ export async function runInit(opts) {
49
50
  p.log.info(`${routes.length} routes found — ${high} high confidence, ${routes.length - high} partial`);
50
51
 
51
52
  const preview = routes.slice(0, 8).map((r) =>
52
- `${r.mutating ? '✗' : '✓'} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? ' (disabled: write-safety)' : ''}`
53
+ `${r.mutating ? c.red('✗') : c.green('✓')} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? c.dim(' (disabled: write-safety)') : ''}`
53
54
  ).join('\n');
54
- p.note(preview + (routes.length > 8 ? `\n... (+${routes.length - 8} more)` : ''), 'TOOLS TO GENERATE');
55
+ p.note(preview + (routes.length > 8 ? c.dim(`\n... (+${routes.length - 8} more)`) : ''), 'TOOLS TO GENERATE');
55
56
 
56
57
  if (flaggedCount) p.log.warn(`${flaggedCount} suspicious docstring(s) purged (prompt-injection defense)`);
57
58
  if (skipped.length) p.log.warn(`${skipped.length} route(s) skipped — details in .sparda/scan-report.json`);
@@ -84,15 +85,15 @@ export async function runInit(opts) {
84
85
  p.log.success('Wrote sparda.json');
85
86
 
86
87
  const cfg = JSON.stringify({ [path.basename(opts.cwd)]: { command: 'npx', args: ['sparda-mcp', 'dev'], cwd: opts.cwd } }, null, 2);
87
- p.outro(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.
88
+ p.outro(`${c.green(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)}
88
89
 
89
90
  Next steps:
90
- 1. Start your app: ${stack.framework === 'express' ? 'npm run dev' : 'fastapi dev'}
91
- 2. Start the MCP bridge: npx sparda-mcp dev
91
+ 1. Start your app: ${c.cyan(stack.framework === 'express' ? 'npm run dev' : 'fastapi dev')}
92
+ 2. Start the MCP bridge: ${c.cyan('npx sparda-mcp dev')}
92
93
  3. Add to Claude Desktop config (claude_desktop_config.json):
93
94
 
94
- ${cfg.split('\n').map((l) => ' ' + l).join('\n')}
95
+ ${colorizeJson(cfg).split('\n').map((l) => ' ' + l).join('\n')}
95
96
 
96
- Write tools (POST/PUT/DELETE) are disabled by default.
97
- Enable them in sparda.json, then re-run \`npx sparda-mcp init --yes\`.`);
97
+ ${c.dim('Write tools (POST/PUT/DELETE) are disabled by default.')}
98
+ ${c.dim('Enable them in sparda.json, then re-run')} ${c.cyan('`npx sparda-mcp init --yes`')}${c.dim('.')}`);
98
99
  }
@@ -5,6 +5,7 @@ import * as p from '@clack/prompts';
5
5
  import { removeInjection as removeExpress } from '../generator/express.js';
6
6
  import { removeInjection as removeFastAPI } from '../generator/fastapi.js';
7
7
  import { detectStack } from '../detect.js';
8
+ import { removeHook } from './hook.js';
8
9
 
9
10
  export async function runRemove(opts) {
10
11
  const manifestPath = path.join(opts.cwd, 'sparda.json');
@@ -43,6 +44,7 @@ export async function runRemove(opts) {
43
44
  if (fs.existsSync(abs)) { fs.rmSync(abs); console.log(`✓ Deleted ${f}`); }
44
45
  }
45
46
  if (revertGitignore(opts.cwd, manifest.gitignore)) console.log('✓ Reverted .gitignore edit');
47
+ if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
46
48
  fs.rmSync(manifestPath);
47
49
  fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });
48
50
  console.log('✓ Deleted sparda.json and .sparda/');
@@ -5,7 +5,7 @@ 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
+ import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
@@ -28,6 +28,28 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
28
28
  }
29
29
  const prev = carryOverManifest(cwd, tools);
30
30
 
31
+ // --- sparding safety memory & fingerprints
32
+ const sparding = defaultSpardingMemory(prev);
33
+ const oldFingerprints = sparding.toolFingerprints ?? {};
34
+ const newFingerprints = {};
35
+ for (const [name, t] of Object.entries(tools)) {
36
+ const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
37
+ const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
38
+ newFingerprints[name] = fp;
39
+ const oldFp = oldFingerprints[name];
40
+ if (oldFp && oldFp !== fp) {
41
+ sparding.events.push({
42
+ ts: new Date().toISOString(),
43
+ tool: name,
44
+ decision: 'audit',
45
+ risk: 'medium',
46
+ reasons: [`route structure modified (fingerprint changed from ${oldFp} to ${fp})`],
47
+ });
48
+ if (sparding.events.length > 100) sparding.events.shift();
49
+ }
50
+ }
51
+ sparding.toolFingerprints = newFingerprints;
52
+
31
53
  // stable across re-runs so a running bridge/host pair never desyncs
32
54
  const localKey = prev?.localKey ?? crypto.randomUUID();
33
55
  const entryAbs = path.resolve(cwd, entryFile);
@@ -61,6 +83,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
61
83
 
62
84
  tpl = tpl
63
85
  .replace('__IMPORT_LINE__', importLine)
86
+ .replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
64
87
  .replace('__ROUTER_DECL__', isESM
65
88
  ? 'export const spardaRouter = express.Router();'
66
89
  : 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };')
@@ -97,6 +120,8 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
97
120
  ...(gitignore ? { gitignore } : {}),
98
121
  ...(prev?.semantic ? { semantic: prev.semantic } : {}),
99
122
  ...(prev?.immune ? { immune: prev.immune } : {}),
123
+ ...(prev?.labs ? { labs: prev.labs } : {}),
124
+ sparding,
100
125
  };
101
126
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
102
127
 
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { toolNameFor } from '../parser/express.js';
8
- import { carryOverManifest } from './manifest.js';
8
+ import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
@@ -28,6 +28,28 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
28
28
  }
29
29
  const prev = carryOverManifest(cwd, tools);
30
30
 
31
+ // --- sparding safety memory & fingerprints
32
+ const sparding = defaultSpardingMemory(prev);
33
+ const oldFingerprints = sparding.toolFingerprints ?? {};
34
+ const newFingerprints = {};
35
+ for (const [name, t] of Object.entries(tools)) {
36
+ const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
37
+ const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
38
+ newFingerprints[name] = fp;
39
+ const oldFp = oldFingerprints[name];
40
+ if (oldFp && oldFp !== fp) {
41
+ sparding.events.push({
42
+ ts: new Date().toISOString(),
43
+ tool: name,
44
+ decision: 'audit',
45
+ risk: 'medium',
46
+ reasons: [`route structure modified (fingerprint changed from ${oldFp} to ${fp})`],
47
+ });
48
+ if (sparding.events.length > 100) sparding.events.shift();
49
+ }
50
+ }
51
+ sparding.toolFingerprints = newFingerprints;
52
+
31
53
  // stable across re-runs so a running bridge/host pair never desyncs
32
54
  const localKey = prev?.localKey ?? crypto.randomUUID();
33
55
  const entryAbs = path.resolve(cwd, entryFile);
@@ -41,6 +63,7 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
41
63
  // double-stringify: a JSON string literal is also a valid Python string literal,
42
64
  // and json.loads() in the template turns it into real Python values (E-009)
43
65
  .replace('__TOOLS_JSON__', JSON.stringify(JSON.stringify(tools)))
66
+ .replace('__SPARDING_POLICIES__', JSON.stringify(JSON.stringify(sparding.policies ?? {})))
44
67
  .replace('__LOCAL_KEY__', localKey)
45
68
  .replace('__PORT__', String(port));
46
69
 
@@ -66,6 +89,8 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
66
89
  ...(gitignore ? { gitignore } : {}),
67
90
  ...(prev?.semantic ? { semantic: prev.semantic } : {}),
68
91
  ...(prev?.immune ? { immune: prev.immune } : {}),
92
+ ...(prev?.labs ? { labs: prev.labs } : {}),
93
+ sparding,
69
94
  };
70
95
 
71
96
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
@@ -2,10 +2,11 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
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.
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), the `labs`
8
+ // state (opt-in flags + observed circuits), and the `sparding` security
9
+ // memory survive as long as the tool's method+path are unchanged.
9
10
  export function carryOverManifest(cwd, tools) {
10
11
  let prev = null;
11
12
  try {
@@ -21,3 +22,18 @@ export function carryOverManifest(cwd, tools) {
21
22
  }
22
23
  return prev;
23
24
  }
25
+
26
+ export function defaultSpardingMemory(prev) {
27
+ return prev?.sparding ?? {
28
+ version: 1,
29
+ policies: {
30
+ reads: 'allow',
31
+ writes: 'require_human',
32
+ deletes: 'block',
33
+ requireProofAfterWrite: true,
34
+ },
35
+ events: [],
36
+ failures: {},
37
+ toolFingerprints: {},
38
+ };
39
+ }
@@ -110,7 +110,7 @@ export function parseExpressProject(cwd, entryFile) {
110
110
  }
111
111
 
112
112
  const fullPath = joinPath(prefix, pathArg.value);
113
- if (fullPath.startsWith('/mcp')) {
113
+ if (fullPath === '/mcp' || fullPath.startsWith('/mcp/')) {
114
114
  skipped.push({ reason: `self-referential path ${fullPath} blocked`, file: rel(absFile), line: p.node.loc?.start.line });
115
115
  return;
116
116
  }
@@ -129,6 +129,14 @@ export function parseExpressProject(cwd, entryFile) {
129
129
 
130
130
  const mutating = method !== 'get';
131
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
+ }
132
140
  let confidence = 'high';
133
141
  if (mutating) {
134
142
  params.push({ name: 'body', in: 'body', type: 'object', required: false, description: 'JSON body — schema not statically detected' });
@@ -172,6 +180,40 @@ export function parseExpressProject(cwd, entryFile) {
172
180
  }
173
181
  }
174
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
+
175
217
  function joinPath(prefix, p) {
176
218
  const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
177
219
  return joined.startsWith('/') ? joined : `/${joined}`;
@@ -27,6 +27,77 @@ class RouteSpecExtractor:
27
27
  lines = [line.strip() for line in doc.splitlines()]
28
28
  return " ".join([l for l in lines[:3] if l]).strip()
29
29
 
30
+ def _is_depends(self, default_node):
31
+ """Returns True if the default value is a Depends(...) call."""
32
+ return (
33
+ isinstance(default_node, ast.Call) and
34
+ ((isinstance(default_node.func, ast.Name) and default_node.func.id == 'Depends') or
35
+ (isinstance(default_node.func, ast.Attribute) and default_node.func.attr == 'Depends'))
36
+ )
37
+
38
+ def _collect_all_imports(self, start_file):
39
+ to_visit = [start_file]
40
+ collected = set()
41
+ visited_imports = set()
42
+
43
+ while to_visit:
44
+ curr = to_visit.pop(0)
45
+ if curr in visited_imports:
46
+ continue
47
+ visited_imports.add(curr)
48
+
49
+ if not os.path.exists(curr):
50
+ continue
51
+
52
+ try:
53
+ with open(curr, 'r', encoding='utf-8') as f:
54
+ src = f.read()
55
+ tree = ast.parse(src, filename=curr)
56
+ except:
57
+ continue
58
+
59
+ for node in ast.walk(tree):
60
+ resolved = None
61
+ if isinstance(node, ast.ImportFrom):
62
+ dots = '.' * node.level if node.level > 0 else ''
63
+ module_with_dots = f"{dots}{node.module}" if node.module else dots
64
+
65
+ for name_node in node.names:
66
+ if node.module:
67
+ full_mod = f"{module_with_dots}.{name_node.name}"
68
+ else:
69
+ full_mod = f"{module_with_dots}{name_node.name}"
70
+ resolved = self.resolve_import(curr, full_mod)
71
+ if resolved and resolved not in collected:
72
+ collected.add(resolved)
73
+ to_visit.append(resolved)
74
+
75
+ resolved = self.resolve_import(curr, module_with_dots)
76
+ if resolved and resolved not in collected:
77
+ collected.add(resolved)
78
+ to_visit.append(resolved)
79
+
80
+ elif isinstance(node, ast.Import):
81
+ for name_node in node.names:
82
+ resolved = self.resolve_import(curr, name_node.name)
83
+ if resolved and resolved not in collected:
84
+ collected.add(resolved)
85
+ to_visit.append(resolved)
86
+
87
+ return collected
88
+
89
+ def preload_models(self):
90
+ """Pre-scan all reachable files for Pydantic models before route extraction."""
91
+ for abs_path in self._collect_all_imports(self.entry_file):
92
+ if abs_path not in self.models:
93
+ try:
94
+ with open(abs_path, 'r', encoding='utf-8') as f:
95
+ src = f.read()
96
+ tree = ast.parse(src, filename=abs_path)
97
+ self.extract_pydantic_models(tree, abs_path)
98
+ except:
99
+ pass
100
+
30
101
  def resolve_import(self, from_file, module_name):
31
102
  if not module_name:
32
103
  return None
@@ -176,10 +247,22 @@ class RouteSpecExtractor:
176
247
  for node in tree.body:
177
248
  # from x import y
178
249
  if isinstance(node, ast.ImportFrom):
179
- resolved = self.resolve_import(abs_file, node.module)
180
- if resolved:
181
- for name_node in node.names:
182
- local_name = name_node.asname or name_node.name
250
+ dots = '.' * node.level if node.level > 0 else ''
251
+ module_with_dots = f"{dots}{node.module}" if node.module else dots
252
+
253
+ for name_node in node.names:
254
+ local_name = name_node.asname or name_node.name
255
+
256
+ if node.module:
257
+ full_mod = f"{module_with_dots}.{name_node.name}"
258
+ else:
259
+ full_mod = f"{module_with_dots}{name_node.name}"
260
+
261
+ resolved = self.resolve_import(abs_file, full_mod)
262
+ if not resolved:
263
+ resolved = self.resolve_import(abs_file, module_with_dots)
264
+
265
+ if resolved:
183
266
  import_map[local_name] = resolved
184
267
 
185
268
  # import x
@@ -221,36 +304,46 @@ class RouteSpecExtractor:
221
304
  if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
222
305
  call = node.value
223
306
  if isinstance(call.func, ast.Attribute) and call.func.attr == 'include_router':
224
- # First arg is the router variable
225
- if len(call.args) >= 1 and isinstance(call.args[0], ast.Name):
226
- router_name = call.args[0].id
227
- mount_prefix = ""
228
- for kw in call.keywords:
229
- if kw.arg == 'prefix':
230
- val = self.get_lit_value(kw.value)
231
- if val is not None:
232
- mount_prefix = val
233
-
234
- resolved_file = import_map.get(router_name)
235
- # Reconstruct cumulative prefix
236
- # parent prefix + mount prefix
237
- cum_prefix = (prefix + mount_prefix).replace('//', '/')
238
- if resolved_file:
239
- self.mounts.append((cum_prefix, resolved_file, router_name))
240
- else:
241
- # If it's a locally defined router
242
- if router_name in router_vars:
243
- local_r_prefix = router_prefixes.get(router_name, "")
244
- self.mounts.append((cum_prefix + local_r_prefix, abs_file, router_name))
307
+ # First arg is the router variable (can be ast.Name or ast.Attribute like users.router)
308
+ if len(call.args) >= 1:
309
+ arg0 = call.args[0]
310
+ router_name = None
311
+ is_attribute = False
312
+ if isinstance(arg0, ast.Name):
313
+ router_name = arg0.id
314
+ elif isinstance(arg0, ast.Attribute) and isinstance(arg0.value, ast.Name):
315
+ router_name = arg0.value.id
316
+ is_attribute = True
317
+
318
+ if router_name:
319
+ mount_prefix = ""
320
+ for kw in call.keywords:
321
+ if kw.arg == 'prefix':
322
+ val = self.get_lit_value(kw.value)
323
+ if val is not None:
324
+ mount_prefix = val
325
+
326
+ resolved_file = import_map.get(router_name)
327
+ # Reconstruct cumulative prefix
328
+ # parent prefix + mount prefix
329
+ cum_prefix = (prefix + mount_prefix).replace('//', '/')
330
+ if resolved_file:
331
+ # If it was users.router, we mount the resolved file with the module's router name
332
+ self.mounts.append((cum_prefix, resolved_file, router_name))
245
333
  else:
246
- self.skipped.append({
247
- 'reason': f"Router '{router_name}' source file not resolved",
248
- 'file': self.get_rel_path(abs_file),
249
- 'line': node.lineno
250
- })
334
+ # If it's a locally defined router
335
+ if not is_attribute and router_name in router_vars:
336
+ local_r_prefix = router_prefixes.get(router_name, "")
337
+ self.mounts.append((cum_prefix + local_r_prefix, abs_file, router_name))
338
+ else:
339
+ self.skipped.append({
340
+ 'reason': f"Router '{router_name}' source file not resolved",
341
+ 'file': self.get_rel_path(abs_file),
342
+ 'line': node.lineno
343
+ })
251
344
 
252
345
  # FunctionDef decorated with app/router decorators
253
- elif isinstance(node, ast.FunctionDef):
346
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
254
347
  for dec in node.decorator_list:
255
348
  if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
256
349
  obj_node = dec.func.value
@@ -283,7 +376,7 @@ class RouteSpecExtractor:
283
376
  full_path = '/' + full_path
284
377
 
285
378
  # Anti-loop skip
286
- if full_path.startswith('/mcp'):
379
+ if full_path == '/mcp' or full_path.startswith('/mcp/'):
287
380
  self.skipped.append({
288
381
  'reason': f"self-referential path {full_path} blocked",
289
382
  'file': self.get_rel_path(abs_file),
@@ -327,6 +420,10 @@ class RouteSpecExtractor:
327
420
 
328
421
  # Check if it has a default value
329
422
  has_default = idx >= defaults_start_idx
423
+ if has_default:
424
+ default_val = node.args.defaults[idx - defaults_start_idx]
425
+ if self._is_depends(default_val):
426
+ continue
330
427
 
331
428
  # Parse type annotation
332
429
  arg_type, type_req = self.parse_type_annotation(arg.annotation)
@@ -403,6 +500,9 @@ class RouteSpecExtractor:
403
500
  self.entry_app_vars = list(app_vars)
404
501
 
405
502
  def run(self):
503
+ # Pre-load Pydantic models from all imported files
504
+ self.preload_models()
505
+
406
506
  # 1st pass: parse entry file
407
507
  self.parse_file(self.entry_file, '', 0)
408
508