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.
@@ -0,0 +1,443 @@
1
+ import sys
2
+ import os
3
+ import ast
4
+ import json
5
+
6
+ class RouteSpecExtractor:
7
+ def __init__(self, entry_file, project_root):
8
+ self.entry_file = os.path.abspath(entry_file)
9
+ self.project_root = os.path.abspath(project_root)
10
+ self.routes = []
11
+ self.skipped = []
12
+ self.visited = set()
13
+ # Maps absolute file paths to a dict of locally defined Pydantic models
14
+ # model_name -> {field_name: {type, required}}
15
+ self.models = {}
16
+ # mounts: list of tuples (parent_prefix, file_path, router_var_name)
17
+ self.mounts = []
18
+ self.entry_app_vars = []
19
+
20
+
21
+ def get_rel_path(self, abs_path):
22
+ return os.path.relpath(abs_path, self.project_root).replace(os.path.sep, '/')
23
+
24
+ def clean_docstring(self, doc):
25
+ if not doc:
26
+ return ""
27
+ lines = [line.strip() for line in doc.splitlines()]
28
+ return " ".join([l for l in lines[:3] if l]).strip()
29
+
30
+ def resolve_import(self, from_file, module_name):
31
+ if not module_name:
32
+ return None
33
+ parts = module_name.split('.')
34
+
35
+ # Relative import (starts with .)
36
+ if module_name.startswith('.'):
37
+ dots = 0
38
+ for char in module_name:
39
+ if char == '.':
40
+ dots += 1
41
+ else:
42
+ break
43
+ clean_module = module_name[dots:]
44
+ base_dir = os.path.dirname(from_file)
45
+ for _ in range(dots - 1):
46
+ base_dir = os.path.dirname(base_dir)
47
+ parts = clean_module.split('.') if clean_module else []
48
+ cand = os.path.join(base_dir, *parts)
49
+ else:
50
+ # Try project-level absolute import
51
+ cand = os.path.join(self.project_root, *parts)
52
+ if not (os.path.isfile(cand + '.py') or os.path.isdir(cand)):
53
+ # Fallback to local import relative to file dir
54
+ cand = os.path.join(os.path.dirname(from_file), *parts)
55
+
56
+ # Check file or __init__.py package
57
+ for p in [cand + '.py', os.path.join(cand, '__init__.py')]:
58
+ if os.path.isfile(p):
59
+ return os.path.abspath(p)
60
+ return None
61
+
62
+ def get_lit_value(self, node):
63
+ if isinstance(node, ast.Constant):
64
+ return node.value
65
+ # Compatibility with older python versions
66
+ elif isinstance(node, ast.Str):
67
+ return node.s
68
+ return None
69
+
70
+ def parse_type_annotation(self, node):
71
+ """Returns (type_str, required_bool)"""
72
+ if node is None:
73
+ return ('string', False)
74
+
75
+ # Simple Name: int, str, float, bool
76
+ if isinstance(node, ast.Name):
77
+ t_map = {'str': 'string', 'int': 'integer', 'float': 'number', 'bool': 'boolean'}
78
+ return (t_map.get(node.id, node.id), True)
79
+
80
+ # typing.Optional[X] or Union[X, None] or Attribute types
81
+ if isinstance(node, ast.Subscript):
82
+ # Optional[X] or Union[X, None]
83
+ value_id = ""
84
+ if isinstance(node.value, ast.Name):
85
+ value_id = node.value.id
86
+ elif isinstance(node.value, ast.Attribute):
87
+ value_id = node.value.attr
88
+
89
+ if value_id in ('Optional', 'Union'):
90
+ # Under Python 3.9+, slice is the index node. Older versions wrapped in Index node.
91
+ slice_node = node.slice
92
+ if isinstance(slice_node, ast.Index):
93
+ slice_node = slice_node.value
94
+
95
+ # If Union, we might have multiple types (e.g. Union[str, int, None])
96
+ if isinstance(slice_node, ast.Tuple):
97
+ types = [self.parse_type_annotation(el) for el in slice_node.elts]
98
+ # Find first non-None type
99
+ non_none = [t for t in types if t[0] != 'None']
100
+ if non_none:
101
+ return (non_none[0][0], False)
102
+ else:
103
+ sub_type, _ = self.parse_type_annotation(slice_node)
104
+ return (sub_type, False)
105
+
106
+ # Python 3.10+ Union Type: X | None
107
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
108
+ left_type, left_req = self.parse_type_annotation(node.left)
109
+ right_type, right_req = self.parse_type_annotation(node.right)
110
+ if left_type == 'None':
111
+ return (right_type, False)
112
+ if right_type == 'None':
113
+ return (left_type, False)
114
+ return (left_type, left_req)
115
+
116
+ # None constant
117
+ if isinstance(node, ast.Constant) and node.value is None:
118
+ return ('None', False)
119
+
120
+ return ('string', True)
121
+
122
+ def extract_pydantic_models(self, tree, abs_file):
123
+ file_models = {}
124
+ for node in tree.body:
125
+ if isinstance(node, ast.ClassDef):
126
+ is_pydantic = False
127
+ for base in node.bases:
128
+ if isinstance(base, ast.Name) and base.id == 'BaseModel':
129
+ is_pydantic = True
130
+ elif isinstance(base, ast.Attribute) and base.attr == 'BaseModel':
131
+ is_pydantic = True
132
+ if is_pydantic:
133
+ fields = {}
134
+ for item in node.body:
135
+ if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
136
+ field_name = item.target.id
137
+ field_type, required = self.parse_type_annotation(item.annotation)
138
+ # Check if it has default value (not required)
139
+ if item.value is not None:
140
+ required = False
141
+ fields[field_name] = {'type': field_type, 'required': required}
142
+ file_models[node.name] = fields
143
+ self.models[abs_file] = file_models
144
+
145
+ def parse_file(self, abs_file, prefix='', depth=0):
146
+ if depth > 2 or abs_file in self.visited:
147
+ return
148
+ self.visited.add(abs_file)
149
+
150
+ if not os.path.exists(abs_file):
151
+ return
152
+
153
+ try:
154
+ with open(abs_file, 'r', encoding='utf-8') as f:
155
+ src = f.read()
156
+ tree = ast.parse(src, filename=abs_file)
157
+ except Exception as e:
158
+ self.skipped.append({
159
+ 'reason': f"Parse error: {str(e)}",
160
+ 'file': self.get_rel_path(abs_file)
161
+ })
162
+ return
163
+
164
+ # 1. Pre-scan for Pydantic Models in this file
165
+ self.extract_pydantic_models(tree, abs_file)
166
+
167
+ # Trace local variables
168
+ app_vars = set()
169
+ router_vars = set()
170
+ # Maps local variable names to resolved file paths
171
+ import_map = {}
172
+ # Maps router variable names to their defined prefix in APIRouter(prefix="/...")
173
+ router_prefixes = {}
174
+
175
+ # 2. Trace imports & assignments
176
+ for node in tree.body:
177
+ # from x import y
178
+ 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
183
+ import_map[local_name] = resolved
184
+
185
+ # import x
186
+ elif isinstance(node, ast.Import):
187
+ for name_node in node.names:
188
+ resolved = self.resolve_import(abs_file, name_node.name)
189
+ if resolved:
190
+ local_name = name_node.asname or name_node.name
191
+ import_map[local_name] = resolved
192
+
193
+ # assignments: app = FastAPI() / router = APIRouter()
194
+ elif isinstance(node, ast.Assign):
195
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
196
+ var_name = node.targets[0].id
197
+ if isinstance(node.value, ast.Call):
198
+ call_func = node.value.func
199
+ call_name = ""
200
+ if isinstance(call_func, ast.Name):
201
+ call_name = call_func.id
202
+ elif isinstance(call_func, ast.Attribute):
203
+ call_name = call_func.attr
204
+
205
+ if call_name == 'FastAPI':
206
+ app_vars.add(var_name)
207
+ elif call_name == 'APIRouter':
208
+ router_vars.add(var_name)
209
+ # Extract APIRouter prefix keyword arg
210
+ r_prefix = ""
211
+ for kw in node.value.keywords:
212
+ if kw.arg == 'prefix':
213
+ val = self.get_lit_value(kw.value)
214
+ if val is not None:
215
+ r_prefix = val
216
+ router_prefixes[var_name] = r_prefix
217
+
218
+ # 3. Trace routes and mounts
219
+ for node in tree.body:
220
+ # app.include_router(router, prefix="/api")
221
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
222
+ call = node.value
223
+ 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))
245
+ 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
+ })
251
+
252
+ # FunctionDef decorated with app/router decorators
253
+ elif isinstance(node, ast.FunctionDef):
254
+ for dec in node.decorator_list:
255
+ if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
256
+ obj_node = dec.func.value
257
+ method = dec.func.attr
258
+
259
+ if method in ('get', 'post', 'put', 'patch', 'delete', 'options', 'head'):
260
+ obj_name = ""
261
+ if isinstance(obj_node, ast.Name):
262
+ obj_name = obj_node.id
263
+
264
+ is_app = obj_name in app_vars
265
+ is_router = obj_name in router_vars
266
+
267
+ if is_app or is_router:
268
+ # Extract path
269
+ if len(dec.args) >= 1:
270
+ raw_path = self.get_lit_value(dec.args[0])
271
+ if raw_path is None:
272
+ self.skipped.append({
273
+ 'reason': f"dynamic path on {method.upper()} (non-literal first arg)",
274
+ 'file': self.get_rel_path(abs_file),
275
+ 'line': node.lineno
276
+ })
277
+ continue
278
+
279
+ # Cumulative prefix path
280
+ local_r_prefix = router_prefixes.get(obj_name, "") if is_router else ""
281
+ full_path = (prefix + local_r_prefix + raw_path).replace('//', '/')
282
+ if not full_path.startswith('/'):
283
+ full_path = '/' + full_path
284
+
285
+ # Anti-loop skip
286
+ if full_path.startswith('/mcp'):
287
+ self.skipped.append({
288
+ 'reason': f"self-referential path {full_path} blocked",
289
+ 'file': self.get_rel_path(abs_file),
290
+ 'line': node.lineno
291
+ })
292
+ continue
293
+
294
+ # Extract description from docstring
295
+ raw_doc = ast.get_docstring(node)
296
+ doc = self.clean_docstring(raw_doc)
297
+
298
+ # Parse signature parameters
299
+ params = []
300
+ mutating = method != 'get'
301
+ confidence = 'high'
302
+ body_properties = {}
303
+ body_required = []
304
+
305
+ # Path params extracted from the path
306
+ path_param_names = set()
307
+ import re
308
+ for m in re.finditer(r'\{(\w+)\}', full_path):
309
+ p_name = m.group(1)
310
+ path_param_names.add(p_name)
311
+ params.append({
312
+ 'name': p_name,
313
+ 'in': 'path',
314
+ 'type': 'string', # will be refined if found in signature
315
+ 'required': True,
316
+ 'description': 'path parameter'
317
+ })
318
+
319
+ # Match args signature to type annotations
320
+ # we also check arg defaults to determine if required
321
+ defaults_start_idx = len(node.args.args) - len(node.args.defaults)
322
+ for idx, arg in enumerate(node.args.args):
323
+ arg_name = arg.arg
324
+ # Skip self/request parameters
325
+ if arg_name in ('self', 'request'):
326
+ continue
327
+
328
+ # Check if it has a default value
329
+ has_default = idx >= defaults_start_idx
330
+
331
+ # Parse type annotation
332
+ arg_type, type_req = self.parse_type_annotation(arg.annotation)
333
+ required = type_req and (not has_default)
334
+
335
+ # If it's a path param, update its type
336
+ is_path_param = False
337
+ for p in params:
338
+ if p['name'] == arg_name and p['in'] == 'path':
339
+ p['type'] = arg_type
340
+ is_path_param = True
341
+ break
342
+
343
+ if is_path_param:
344
+ continue
345
+
346
+ # Check if arg_type refers to a scannable Pydantic model
347
+ # We scan all visited files models
348
+ matched_model = None
349
+ for f_path, f_models in self.models.items():
350
+ if arg_type in f_models:
351
+ matched_model = f_models[arg_type]
352
+ break
353
+
354
+ if matched_model is not None:
355
+ # It's a Pydantic body parameter
356
+ for f_name, f_spec in matched_model.items():
357
+ body_properties[f_name] = {'type': f_spec['type']}
358
+ if f_spec['required']:
359
+ body_properties[f_name]['required'] = True
360
+ body_required.append(f_name)
361
+ else:
362
+ # Query parameter
363
+ params.append({
364
+ 'name': arg_name,
365
+ 'in': 'query',
366
+ 'type': arg_type,
367
+ 'required': required,
368
+ 'description': 'query parameter'
369
+ })
370
+
371
+ if mutating:
372
+ if body_properties:
373
+ params.append({
374
+ 'name': 'body',
375
+ 'in': 'body',
376
+ 'type': 'object',
377
+ 'required': True,
378
+ 'properties': body_properties
379
+ })
380
+ else:
381
+ # Fallback body
382
+ params.append({
383
+ 'name': 'body',
384
+ 'in': 'body',
385
+ 'type': 'object',
386
+ 'required': False,
387
+ 'description': 'JSON body — schema not statically detected'
388
+ })
389
+ confidence = 'low'
390
+
391
+ self.routes.append({
392
+ 'method': method,
393
+ 'path': full_path,
394
+ 'handlerName': node.name,
395
+ 'sourceFile': self.get_rel_path(abs_file),
396
+ 'sourceLine': node.lineno,
397
+ 'params': params,
398
+ 'description': doc,
399
+ 'mutating': mutating,
400
+ 'confidence': confidence
401
+ })
402
+ if depth == 0:
403
+ self.entry_app_vars = list(app_vars)
404
+
405
+ def run(self):
406
+ # 1st pass: parse entry file
407
+ self.parse_file(self.entry_file, '', 0)
408
+
409
+ # 2nd pass: parse mounted routers
410
+ for prefix, file_path, router_name in list(self.mounts):
411
+ self.parse_file(file_path, prefix, 1)
412
+
413
+ # Deduplicate routes
414
+ seen = set()
415
+ deduped = []
416
+ for r in self.routes:
417
+ key = f"{r['method']} {r['path']}"
418
+ if key not in seen:
419
+ seen.add(key)
420
+ deduped.append(r)
421
+
422
+ return {
423
+ 'routes': deduped,
424
+ 'skipped': self.skipped,
425
+ 'entryAppVars': getattr(self, 'entry_app_vars', [])
426
+ }
427
+
428
+ if __name__ == '__main__':
429
+ if len(sys.argv) < 3:
430
+ print(json.dumps({'error': 'Usage: python fastapi_extract.py <entry_file> <project_root>'}))
431
+ sys.exit(1)
432
+
433
+ entry = sys.argv[1]
434
+ root = sys.argv[2]
435
+
436
+ try:
437
+ extractor = RouteSpecExtractor(entry, root)
438
+ result = extractor.run()
439
+ print(json.dumps(result))
440
+ except Exception as e:
441
+ import traceback
442
+ print(json.dumps({'error': f"Internal extractor error: {str(e)}\n{traceback.format_exc()}"}))
443
+ sys.exit(1)