sparda-mcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +89 -0
- package/README.md +50 -7
- package/package.json +6 -4
- package/src/commands/doctor.js +45 -7
- package/src/commands/hook.js +29 -0
- package/src/commands/init.js +26 -7
- package/src/commands/remove.js +48 -3
- package/src/commands/sync.js +49 -0
- package/src/detect.js +98 -2
- package/src/generator/express.js +46 -8
- package/src/generator/fastapi.js +211 -0
- package/src/generator/manifest.js +23 -0
- package/src/index.js +22 -3
- package/src/parser/express.js +27 -7
- package/src/parser/fastapi.js +26 -0
- package/src/parser/fastapi_extract.py +443 -0
- package/src/server/stdio.js +271 -29
- package/templates/express-router.txt +57 -4
- package/templates/fastapi-router.txt +198 -0
|
@@ -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)
|