mcpxray-cli 1.0.0__py3-none-any.whl

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,316 @@
1
+ """Static Python extractor.
2
+
3
+ Parses MCP server source with :mod:`ast` (no import, no execution) and builds a
4
+ :class:`~mcpxray.ir.McpServer`. Recognises the FastMCP / ``server.tool()``
5
+ decorator family::
6
+
7
+ mcp = FastMCP("...")
8
+
9
+ @mcp.tool()
10
+ def add(a: int, b: int) -> int:
11
+ \"\"\"Add two integers.\"\"\"
12
+ ...
13
+
14
+ The tool's description comes from the decorator's ``description=`` kwarg or the
15
+ function docstring; the input schema is derived from parameter type hints.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ import os
22
+ import re
23
+ from pathlib import Path
24
+
25
+ try: # Python 3.11+
26
+ import tomllib
27
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10
28
+ import tomli as tomllib # type: ignore[no-redef]
29
+
30
+ from mcpxray.extract.base import Extractor, register_extractor
31
+ from mcpxray.ir import SOURCE_STATIC, McpServer, ServerMeta, Tool
32
+
33
+ # Directories never to descend into when walking a source tree.
34
+ _SKIP_DIRS = {
35
+ ".git",
36
+ ".venv",
37
+ "venv",
38
+ "env",
39
+ "__pycache__",
40
+ "build",
41
+ "dist",
42
+ "node_modules",
43
+ ".mypy_cache",
44
+ ".ruff_cache",
45
+ ".pytest_cache",
46
+ "site-packages",
47
+ # Test/fixture trees are never application source and routinely hold fake
48
+ # keys + example tool registrations that would pollute a real scan (e.g. the
49
+ # modelcontextprotocol/python-sdk demo inflated to 420 tools / 34 secrets).
50
+ "tests",
51
+ "test",
52
+ "testing",
53
+ "fixtures",
54
+ "fixture",
55
+ "test_data",
56
+ "testdata",
57
+ "testdata_dir",
58
+ }
59
+
60
+
61
+ def _is_test_dir(name: str) -> bool:
62
+ """``test_foo`` / ``foo_test`` style dirs (a set can't express prefixes)."""
63
+ return name.startswith("test_") or name.endswith("_test")
64
+
65
+
66
+ _PRIMITIVES = {"str": "string", "int": "integer", "float": "number", "bool": "boolean"}
67
+ _CONTAINER_SEQ = {"list", "List", "set", "Set", "frozenset", "tuple", "Tuple", "Sequence"}
68
+ _CONTAINER_MAP = {"dict", "Dict", "Mapping", "OrderedDict"}
69
+ _OPTIONAL = {"Optional"}
70
+ _UNION = {"Union"}
71
+
72
+
73
+ # --- type-hint (AST) → JSON Schema fragment ---------------------------------
74
+
75
+
76
+ def _is_none(node: ast.AST) -> bool:
77
+ return (isinstance(node, ast.Constant) and node.value is None) or (
78
+ isinstance(node, ast.Name) and node.id == "NoneType"
79
+ )
80
+
81
+
82
+ def _flatten_bitor(node: ast.AST) -> list[ast.AST]:
83
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
84
+ return _flatten_bitor(node.left) + _flatten_bitor(node.right)
85
+ return [node]
86
+
87
+
88
+ def _subscript_container(node: ast.Subscript) -> str:
89
+ value = node.value
90
+ return value.id if isinstance(value, ast.Name) else ""
91
+
92
+
93
+ def _slice_items(node: ast.AST) -> tuple[ast.AST, ...]:
94
+ slice_node = node
95
+ if isinstance(slice_node, ast.Tuple):
96
+ return slice_node.elts
97
+ return (slice_node,)
98
+
99
+
100
+ def _annotation_to_schema(node: ast.AST | None) -> dict:
101
+ """Best-effort mapping of an AST annotation to a JSON Schema fragment."""
102
+ if node is None:
103
+ return {}
104
+ if isinstance(node, ast.Name):
105
+ if node.id in _PRIMITIVES:
106
+ return {"type": _PRIMITIVES[node.id]}
107
+ return {} # Any / custom class — we can't resolve it statically
108
+ if isinstance(node, ast.Constant):
109
+ return {}
110
+ if isinstance(node, ast.Subscript):
111
+ container = _subscript_container(node)
112
+ items = _slice_items(node.slice)
113
+ if container in _CONTAINER_SEQ:
114
+ inner = items[0] if items else None
115
+ return {"type": "array", "items": _annotation_to_schema(inner)}
116
+ if container in _CONTAINER_MAP:
117
+ return {"type": "object"}
118
+ if container in _OPTIONAL:
119
+ return _annotation_to_schema(items[0] if items else None)
120
+ if container in _UNION:
121
+ non_none = [t for t in items if not _is_none(t)]
122
+ return _annotation_to_schema(non_none[0]) if len(non_none) == 1 else {}
123
+ return {}
124
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): # PEP 604 X | Y
125
+ non_none = [p for p in _flatten_bitor(node) if not _is_none(p)]
126
+ return _annotation_to_schema(non_none[0]) if len(non_none) == 1 else {}
127
+ return {} # ast.Attribute (qualified type) etc.
128
+
129
+
130
+ # --- decorator detection + tool metadata ------------------------------------
131
+
132
+
133
+ def _decorator_is_tool(node: ast.AST) -> bool:
134
+ target = node.func if isinstance(node, ast.Call) else node
135
+ return (isinstance(target, ast.Attribute) and target.attr == "tool") or (
136
+ isinstance(target, ast.Name) and target.id == "tool"
137
+ )
138
+
139
+
140
+ def _decorator_meta(node: ast.AST) -> tuple[str | None, str | None]:
141
+ """Extract ``name=`` / ``description=`` kwargs from a tool decorator call."""
142
+ name = desc = None
143
+ if isinstance(node, ast.Call):
144
+ for kw in node.keywords:
145
+ if kw.arg == "name" and isinstance(kw.value, ast.Constant):
146
+ name = kw.value.value if isinstance(kw.value.value, str) else None
147
+ elif kw.arg == "description" and isinstance(kw.value, ast.Constant):
148
+ desc = kw.value.value if isinstance(kw.value.value, str) else None
149
+ return name, desc
150
+
151
+
152
+ def _looks_like_context(arg_name: str, annotation: ast.AST | None) -> bool:
153
+ """True if this parameter is the MCP request context (framework-injected, not user input).
154
+
155
+ FastMCP injects a ``Context`` object into every tool; by convention it is named
156
+ ``ctx`` and/or typed ``Context`` (or ``mcp.Context``). Like the SDK, we exclude
157
+ it from the input schema so it isn't mistaken for a user-supplied argument.
158
+ """
159
+ if arg_name == "ctx":
160
+ return True
161
+ if isinstance(annotation, ast.Name):
162
+ return annotation.id == "Context"
163
+ return isinstance(annotation, ast.Attribute) and annotation.attr == "Context"
164
+
165
+
166
+ def _build_input_schema(func: ast.FunctionDef | ast.AsyncFunctionDef) -> dict:
167
+ props: dict[str, dict] = {}
168
+ required: list[str] = []
169
+ args = func.args
170
+
171
+ posargs = list(args.posonlyargs) + list(args.args)
172
+ defaults = args.defaults # align to the right across posonly + posorkw
173
+ ndef = len(defaults)
174
+ npos = len(posargs)
175
+ for i, a in enumerate(posargs):
176
+ if a.arg in ("self", "cls") or _looks_like_context(a.arg, a.annotation):
177
+ continue
178
+ props[a.arg] = _annotation_to_schema(a.annotation)
179
+ if i < npos - ndef:
180
+ required.append(a.arg)
181
+
182
+ for i, a in enumerate(args.kwonlyargs):
183
+ if _looks_like_context(a.arg, a.annotation):
184
+ continue
185
+ props[a.arg] = _annotation_to_schema(a.annotation)
186
+ if args.kw_defaults[i] is None:
187
+ required.append(a.arg)
188
+
189
+ schema: dict = {"type": "object", "properties": props}
190
+ if required:
191
+ schema["required"] = required
192
+ return schema
193
+
194
+
195
+ def _extract_function(
196
+ func: ast.FunctionDef | ast.AsyncFunctionDef,
197
+ source_path: str,
198
+ ) -> Tool | None:
199
+ tool_deco = next((d for d in func.decorator_list if _decorator_is_tool(d)), None)
200
+ if tool_deco is None:
201
+ return None
202
+ deco_name, deco_desc = _decorator_meta(tool_deco)
203
+ docstring = ast.get_docstring(func, clean=True)
204
+ return Tool(
205
+ name=deco_name or func.name,
206
+ description=deco_desc or docstring,
207
+ input_schema=_build_input_schema(func),
208
+ source_path=source_path,
209
+ line=func.lineno,
210
+ )
211
+
212
+
213
+ def _extract_file(path: Path, server: McpServer) -> None:
214
+ text = path.read_text(encoding="utf-8")
215
+ posix = path.as_posix()
216
+ server.sources[posix] = text
217
+ try:
218
+ tree = ast.parse(text, filename=str(path))
219
+ except SyntaxError:
220
+ return # unparseable file — leave to rules/CI to surface separately
221
+ for node in ast.walk(tree):
222
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
223
+ tool = _extract_function(node, posix)
224
+ if tool is not None:
225
+ server.tools.append(tool)
226
+
227
+
228
+ # --- project metadata (dependencies, lockfiles) for supply-chain rules -------
229
+
230
+ _DEP_RE = re.compile(r"^\s*([A-Za-z0-9_.-]+)\s*(.*)$")
231
+ _LOCKFILES = {"uv.lock", "poetry.lock", "Pipfile.lock", "pdm.lock", "requirements.txt"}
232
+
233
+
234
+ def _split_dep(dep: str) -> tuple[str, str]:
235
+ m = _DEP_RE.match(dep)
236
+ if not m:
237
+ return "", ""
238
+ return m.group(1), m.group(2).strip()
239
+
240
+
241
+ def _load_pyproject(root: Path, server: McpServer) -> None:
242
+ pyproject = root / "pyproject.toml"
243
+ if not pyproject.is_file():
244
+ return
245
+ try:
246
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
247
+ except (tomllib.TOMLDecodeError, OSError):
248
+ return
249
+ for dep in (data.get("project") or {}).get("dependencies") or []:
250
+ name, spec = _split_dep(str(dep))
251
+ if name:
252
+ server.dependencies[name] = spec
253
+ if server.dependencies:
254
+ server.dep_file = str(pyproject) # provenance for `--fix`
255
+
256
+
257
+ def _find_lockfiles(root: Path, server: McpServer) -> None:
258
+ for name in _LOCKFILES:
259
+ if (root / name).is_file():
260
+ server.lockfiles.append(name)
261
+
262
+
263
+ _PY_EXTS = (".py",)
264
+
265
+
266
+ def _iter_source_files(root: Path, exts: tuple[str, ...]) -> list[Path]:
267
+ """Walk ``root`` for files whose suffix is in ``exts``, pruning noise dirs.
268
+
269
+ Shared by the Python and TypeScript extractors (and by scope detection) so the
270
+ skip-dir policy lives in one place.
271
+ """
272
+ files: list[Path] = []
273
+ for dirpath, dirnames, filenames in os.walk(root):
274
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not _is_test_dir(d)]
275
+ for name in filenames:
276
+ if name.endswith(exts):
277
+ files.append(Path(dirpath) / name)
278
+ return sorted(files)
279
+
280
+
281
+ def _iter_python_files(root: Path) -> list[Path]:
282
+ """Python source files under ``root`` (``_iter_source_files`` specialised)."""
283
+ return _iter_source_files(root, _PY_EXTS)
284
+
285
+
286
+ @register_extractor
287
+ class PythonExtractor(Extractor):
288
+ """Extract tools from Python MCP server source (FastMCP ``@mcp.tool()``)."""
289
+
290
+ language = "python"
291
+
292
+ def applies_to(self, path: Path) -> bool:
293
+ if path.is_file():
294
+ return path.suffix == ".py"
295
+ if path.is_dir():
296
+ return any(f.suffix == ".py" for f in _iter_python_files(path))
297
+ return False
298
+
299
+ def extract(self, path: Path, *, root: Path | None = None) -> McpServer:
300
+ files = [path] if path.is_file() else _iter_python_files(path)
301
+ server = McpServer(
302
+ meta=ServerMeta(
303
+ name=path.stem if path.is_file() else path.name,
304
+ language=self.language,
305
+ path=str(path),
306
+ ),
307
+ source_mode=SOURCE_STATIC,
308
+ )
309
+ for f in files:
310
+ _extract_file(f, server)
311
+ # Metadata (deps/lockfiles) lives at the project root, which may be wider
312
+ # than the scan scope when the caller narrowed ``path`` to a subpackage.
313
+ meta_root = root or (path if path.is_dir() else path.parent)
314
+ _load_pyproject(meta_root, server)
315
+ _find_lockfiles(meta_root, server)
316
+ return server