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.
- mcpxray/__init__.py +18 -0
- mcpxray/__main__.py +6 -0
- mcpxray/badge.py +49 -0
- mcpxray/cli.py +322 -0
- mcpxray/extract/__init__.py +16 -0
- mcpxray/extract/base.py +61 -0
- mcpxray/extract/manifest.py +97 -0
- mcpxray/extract/python_static.py +316 -0
- mcpxray/extract/typescript_static.py +552 -0
- mcpxray/fix.py +166 -0
- mcpxray/ir.py +186 -0
- mcpxray/report/__init__.py +47 -0
- mcpxray/report/card.py +137 -0
- mcpxray/report/github.py +35 -0
- mcpxray/report/json.py +50 -0
- mcpxray/report/plain.py +35 -0
- mcpxray/report/sarif.py +76 -0
- mcpxray/rules/__init__.py +8 -0
- mcpxray/rules/base.py +67 -0
- mcpxray/rules/builtin/__init__.py +5 -0
- mcpxray/rules/builtin/descriptions.py +103 -0
- mcpxray/rules/builtin/schema.py +115 -0
- mcpxray/rules/builtin/source.py +95 -0
- mcpxray/rules/builtin/supply.py +92 -0
- mcpxray/rules/builtin/transport.py +79 -0
- mcpxray/runtime.py +260 -0
- mcpxray/score.py +71 -0
- mcpxray/source.py +253 -0
- mcpxray/verdict.py +139 -0
- mcpxray_cli-1.0.0.dist-info/METADATA +211 -0
- mcpxray_cli-1.0.0.dist-info/RECORD +33 -0
- mcpxray_cli-1.0.0.dist-info/WHEEL +4 -0
- mcpxray_cli-1.0.0.dist-info/entry_points.txt +7 -0
|
@@ -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
|