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
mcpxray/runtime.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Runtime capture — spawn an MCP server and read its ``tools/list`` over stdio.
|
|
2
|
+
|
|
3
|
+
For servers whose tools can't be discovered statically (compiled, 3rd-party, or
|
|
4
|
+
built dynamically at runtime), this opt-in path launches the server, performs
|
|
5
|
+
the MCP JSON-RPC 2.0 handshake (``initialize`` → ``notifications/initialized``
|
|
6
|
+
→ ``tools/list``) over its stdio transport, and returns the declared tools.
|
|
7
|
+
The CLI feeds the result to :func:`mcpxray.extract.manifest.from_tools`, so the
|
|
8
|
+
rest of the pipeline (rules → score → verdict) is reused unchanged.
|
|
9
|
+
|
|
10
|
+
.. warning::
|
|
11
|
+
|
|
12
|
+
This **executes the server under inspection**, which may be untrusted code.
|
|
13
|
+
It is strictly opt-in (``--runtime --command``) and provides process
|
|
14
|
+
isolation + bounded lifetime only — **there is no OS-level sandbox** (no
|
|
15
|
+
filesystem or network isolation). Only point it at servers you trust, or run
|
|
16
|
+
mcpxray inside a container/VM when inspecting untrusted servers.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
import signal
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from mcpxray import __version__
|
|
31
|
+
|
|
32
|
+
# --- protocol constants -------------------------------------------------------
|
|
33
|
+
_PROTOCOL_VERSION = "2024-11-05"
|
|
34
|
+
_INIT_ID = 1
|
|
35
|
+
_LIST_ID = 2
|
|
36
|
+
_EXIT_TIMEOUT = 3.0 # grace period for the server to exit on EOF before we kill it
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CaptureError(RuntimeError):
|
|
40
|
+
"""Raised for any spawn / handshake / timeout / parse failure during capture."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CaptureResult:
|
|
45
|
+
"""The outcome of a successful ``tools/list`` capture."""
|
|
46
|
+
|
|
47
|
+
tools: list[dict]
|
|
48
|
+
server_name: str | None
|
|
49
|
+
server_version: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- message builders (pure, unit-tested) ------------------------------------
|
|
53
|
+
def _initialize_msg(req_id: int) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"jsonrpc": "2.0",
|
|
56
|
+
"id": req_id,
|
|
57
|
+
"method": "initialize",
|
|
58
|
+
"params": {
|
|
59
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
60
|
+
"capabilities": {},
|
|
61
|
+
"clientInfo": {"name": "mcpxray", "version": __version__},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _initialized_notification() -> dict:
|
|
67
|
+
# A notification carries no ``id`` and expects no response.
|
|
68
|
+
return {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tools_list_msg(req_id: int) -> dict:
|
|
72
|
+
return {"jsonrpc": "2.0", "id": req_id, "method": "tools/list", "params": {}}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def split_command(command: str) -> list[str]:
|
|
76
|
+
"""POSIX-split a ``--command`` string into an argv list (raises if empty)."""
|
|
77
|
+
argv = shlex.split(command)
|
|
78
|
+
if not argv:
|
|
79
|
+
raise CaptureError(f"could not parse a launch command from {command!r}")
|
|
80
|
+
return argv
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- stdio transport ----------------------------------------------------------
|
|
84
|
+
async def _send(proc: asyncio.subprocess.Process, msg: dict) -> None:
|
|
85
|
+
if proc.stdin is None:
|
|
86
|
+
raise CaptureError("server has no stdin to write to")
|
|
87
|
+
proc.stdin.write((json.dumps(msg) + "\n").encode("utf-8"))
|
|
88
|
+
await proc.stdin.drain()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _read_response(
|
|
92
|
+
proc: asyncio.subprocess.Process, expected_id: int, timeout: float, label: str
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Read newline-delimited JSON until the response with ``expected_id`` arrives."""
|
|
95
|
+
|
|
96
|
+
async def _read() -> dict:
|
|
97
|
+
assert proc.stdout is not None
|
|
98
|
+
while True:
|
|
99
|
+
line = await proc.stdout.readline()
|
|
100
|
+
if not line:
|
|
101
|
+
raise CaptureError(f"server closed its output before answering {label}")
|
|
102
|
+
text = line.decode("utf-8", "replace").strip()
|
|
103
|
+
if not text:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
obj = json.loads(text)
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
continue # non-JSON line (log noise on stdout) — skip
|
|
109
|
+
if isinstance(obj, dict) and obj.get("id") == expected_id:
|
|
110
|
+
return obj
|
|
111
|
+
# otherwise a notification / unrelated response — keep reading
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return await asyncio.wait_for(_read(), timeout=timeout)
|
|
115
|
+
except asyncio.TimeoutError as e:
|
|
116
|
+
raise CaptureError(f"timed out waiting for the server's {label} response") from e
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _drain_stderr(stderr: asyncio.StreamReader, sink: list[str]) -> None:
|
|
120
|
+
"""Continuously read stderr so a full pipe buffer can't deadlock the server."""
|
|
121
|
+
try:
|
|
122
|
+
while True:
|
|
123
|
+
chunk = await stderr.read(4096)
|
|
124
|
+
if not chunk:
|
|
125
|
+
break
|
|
126
|
+
sink.append(chunk.decode("utf-8", "replace"))
|
|
127
|
+
except (OSError, asyncio.LimitOverrunError):
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _kill(proc: asyncio.subprocess.Process) -> None:
|
|
132
|
+
"""Force-kill the server (and its process group, where supported)."""
|
|
133
|
+
try:
|
|
134
|
+
if os.name == "nt":
|
|
135
|
+
proc.kill() # TerminateProcess on the direct child only
|
|
136
|
+
else:
|
|
137
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # whole session/group
|
|
138
|
+
except (ProcessLookupError, OSError):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _shutdown(proc: asyncio.subprocess.Process) -> None:
|
|
143
|
+
"""Close stdin, wait briefly for exit, then force-kill if still alive."""
|
|
144
|
+
if proc.stdin is not None:
|
|
145
|
+
with contextlib.suppress(BaseException):
|
|
146
|
+
proc.stdin.close()
|
|
147
|
+
try:
|
|
148
|
+
await asyncio.wait_for(proc.wait(), timeout=_EXIT_TIMEOUT)
|
|
149
|
+
return
|
|
150
|
+
except asyncio.TimeoutError:
|
|
151
|
+
pass
|
|
152
|
+
_kill(proc)
|
|
153
|
+
with contextlib.suppress(Exception):
|
|
154
|
+
await proc.wait()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _spawn(argv: list[str], cwd: Path | None) -> asyncio.subprocess.Process:
|
|
158
|
+
kwargs: dict = {}
|
|
159
|
+
if cwd is not None:
|
|
160
|
+
kwargs["cwd"] = str(cwd)
|
|
161
|
+
if os.name != "nt":
|
|
162
|
+
kwargs["start_new_session"] = True # own process group → killable as a unit
|
|
163
|
+
try:
|
|
164
|
+
return await asyncio.create_subprocess_exec(
|
|
165
|
+
*argv,
|
|
166
|
+
stdin=asyncio.subprocess.PIPE,
|
|
167
|
+
stdout=asyncio.subprocess.PIPE,
|
|
168
|
+
stderr=asyncio.subprocess.PIPE,
|
|
169
|
+
**kwargs,
|
|
170
|
+
)
|
|
171
|
+
except FileNotFoundError as e:
|
|
172
|
+
raise CaptureError(f"could not start server: executable not found ({argv[0]!r})") from e
|
|
173
|
+
except OSError as e:
|
|
174
|
+
raise CaptureError(f"could not start server: {e}") from e
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _handshake(
|
|
178
|
+
proc: asyncio.subprocess.Process, init_timeout: float, list_timeout: float
|
|
179
|
+
) -> CaptureResult:
|
|
180
|
+
await _send(proc, _initialize_msg(_INIT_ID))
|
|
181
|
+
init_resp = await _read_response(proc, _INIT_ID, init_timeout, "initialize")
|
|
182
|
+
init_result = init_resp.get("result")
|
|
183
|
+
init_result = init_result if isinstance(init_result, dict) else {}
|
|
184
|
+
info = init_result.get("serverInfo")
|
|
185
|
+
info = info if isinstance(info, dict) else {}
|
|
186
|
+
server_name = info.get("name") if isinstance(info.get("name"), str) else None
|
|
187
|
+
server_version = info.get("version") if isinstance(info.get("version"), str) else None
|
|
188
|
+
|
|
189
|
+
await _send(proc, _initialized_notification())
|
|
190
|
+
await _send(proc, _tools_list_msg(_LIST_ID))
|
|
191
|
+
list_resp = await _read_response(proc, _LIST_ID, list_timeout, "tools/list")
|
|
192
|
+
if isinstance(list_resp.get("error"), dict):
|
|
193
|
+
msg = list_resp["error"].get("message", "tools/list returned an error")
|
|
194
|
+
raise CaptureError(f"tools/list failed: {msg}")
|
|
195
|
+
|
|
196
|
+
list_result = list_resp.get("result")
|
|
197
|
+
list_result = list_result if isinstance(list_result, dict) else {}
|
|
198
|
+
tools = list_result.get("tools")
|
|
199
|
+
if not isinstance(tools, list):
|
|
200
|
+
raise CaptureError("tools/list did not return a 'tools' list")
|
|
201
|
+
|
|
202
|
+
return CaptureResult(tools=tools, server_name=server_name, server_version=server_version)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _capture(
|
|
206
|
+
argv: list[str], *, cwd: Path | None, init_timeout: float, list_timeout: float
|
|
207
|
+
) -> CaptureResult:
|
|
208
|
+
proc = await _spawn(argv, cwd)
|
|
209
|
+
# Drain stderr in the background: a server that logs heavily to stderr would
|
|
210
|
+
# otherwise fill the OS pipe buffer and deadlock before we read its stdout.
|
|
211
|
+
stderr_sink: list[str] = []
|
|
212
|
+
stderr_task = (
|
|
213
|
+
asyncio.create_task(_drain_stderr(proc.stderr, stderr_sink))
|
|
214
|
+
if proc.stderr is not None
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
err: CaptureError | None = None
|
|
219
|
+
result: CaptureResult | None = None
|
|
220
|
+
try:
|
|
221
|
+
result = await _handshake(proc, init_timeout, list_timeout)
|
|
222
|
+
except CaptureError as e:
|
|
223
|
+
err = e
|
|
224
|
+
except Exception as e: # noqa: BLE001 — surface any protocol-level surprise
|
|
225
|
+
err = CaptureError(f"unexpected error during capture: {e}")
|
|
226
|
+
|
|
227
|
+
# Shut down first: killing the process closes the pipes, letting the drainer finish.
|
|
228
|
+
await _shutdown(proc)
|
|
229
|
+
if stderr_task is not None:
|
|
230
|
+
stderr_task.cancel()
|
|
231
|
+
with contextlib.suppress(BaseException):
|
|
232
|
+
await stderr_task
|
|
233
|
+
|
|
234
|
+
if err is not None:
|
|
235
|
+
tail = "".join(stderr_sink).strip()
|
|
236
|
+
if tail:
|
|
237
|
+
msg = f"{err.args[0]}\n server stderr (tail):\n {tail[-1200:]}"
|
|
238
|
+
raise CaptureError(msg) from None
|
|
239
|
+
raise err
|
|
240
|
+
assert result is not None
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def capture_tools(
|
|
245
|
+
argv: list[str],
|
|
246
|
+
*,
|
|
247
|
+
cwd: Path | None = None,
|
|
248
|
+
init_timeout: float = 15.0,
|
|
249
|
+
list_timeout: float = 15.0,
|
|
250
|
+
) -> CaptureResult:
|
|
251
|
+
"""Spawn ``argv``, run the MCP stdio handshake, return the captured tools.
|
|
252
|
+
|
|
253
|
+
Raises :class:`CaptureError` on any spawn / handshake / timeout / parse
|
|
254
|
+
failure. Synchronous wrapper over the async implementation.
|
|
255
|
+
"""
|
|
256
|
+
if not argv:
|
|
257
|
+
raise CaptureError("no launch command provided")
|
|
258
|
+
return asyncio.run(
|
|
259
|
+
_capture(argv, cwd=cwd, init_timeout=init_timeout, list_timeout=list_timeout)
|
|
260
|
+
)
|
mcpxray/score.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Score engine — collapse findings into a 0-100 score with an error cap.
|
|
2
|
+
|
|
3
|
+
Mirrors OpenSSF Scorecard's shape (risk-weighted, hard cap so a single critical
|
|
4
|
+
finding can't be diluted): each finding deducts points by severity, and any
|
|
5
|
+
ERROR-severity finding caps the result at :data:`~mcpxray.ir.ERROR_SCORE_CAP`
|
|
6
|
+
so a server with a leaked secret or poisoned tool never scores green.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from mcpxray.ir import ERROR_SCORE_CAP, SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING, McpServer
|
|
14
|
+
|
|
15
|
+
# Points deducted per finding, by severity.
|
|
16
|
+
_DEDUCTION = {
|
|
17
|
+
SEVERITY_ERROR: 20,
|
|
18
|
+
SEVERITY_WARNING: 6,
|
|
19
|
+
SEVERITY_INFO: 1,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_MAX_SCORE = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ScoreResult:
|
|
27
|
+
"""The outcome of scoring a server."""
|
|
28
|
+
|
|
29
|
+
score: int
|
|
30
|
+
errors: int
|
|
31
|
+
warnings: int
|
|
32
|
+
infos: int
|
|
33
|
+
capped: bool # True when the error-cap lowered the score
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def grade(self) -> str:
|
|
37
|
+
"""Letter grade: A ≥90, B ≥80, C ≥70, D ≥60, F <60."""
|
|
38
|
+
if self.score >= 90:
|
|
39
|
+
return "A"
|
|
40
|
+
if self.score >= 80:
|
|
41
|
+
return "B"
|
|
42
|
+
if self.score >= 70:
|
|
43
|
+
return "C"
|
|
44
|
+
if self.score >= 60:
|
|
45
|
+
return "D"
|
|
46
|
+
return "F"
|
|
47
|
+
|
|
48
|
+
def passed(self, fail_under: int) -> bool:
|
|
49
|
+
return self.score >= fail_under
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def score(doc: McpServer) -> ScoreResult:
|
|
53
|
+
"""Score a server from its diagnostics (run :func:`mcpxray.rules.run_all` first)."""
|
|
54
|
+
errors = sum(1 for d in doc.diagnostics if d.severity == SEVERITY_ERROR)
|
|
55
|
+
warnings = sum(1 for d in doc.diagnostics if d.severity == SEVERITY_WARNING)
|
|
56
|
+
infos = sum(1 for d in doc.diagnostics if d.severity == SEVERITY_INFO)
|
|
57
|
+
|
|
58
|
+
deducted = sum(_DEDUCTION.get(d.severity, 0) for d in doc.diagnostics)
|
|
59
|
+
raw = _MAX_SCORE - deducted
|
|
60
|
+
|
|
61
|
+
capped = errors > 0
|
|
62
|
+
final = min(raw, ERROR_SCORE_CAP) if capped else raw
|
|
63
|
+
final = max(0, min(_MAX_SCORE, final))
|
|
64
|
+
|
|
65
|
+
return ScoreResult(
|
|
66
|
+
score=final,
|
|
67
|
+
errors=errors,
|
|
68
|
+
warnings=warnings,
|
|
69
|
+
infos=infos,
|
|
70
|
+
capped=capped,
|
|
71
|
+
)
|
mcpxray/source.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Resolve a CLI target (URL / local path / manifest) to a local source.
|
|
2
|
+
|
|
3
|
+
The friendly ``check`` command accepts a GitHub-style URL and clones it into a
|
|
4
|
+
temporary directory so the existing Python extractor can run over it — no new
|
|
5
|
+
dependencies, just ``git`` on the PATH and the standard library. URL handling
|
|
6
|
+
lives here (not under :mod:`mcpxray.extract`) because cloning is a transport
|
|
7
|
+
concern, not extraction: extractors build an IR from an already-local path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
try: # Python 3.11+
|
|
21
|
+
import tomllib
|
|
22
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10
|
|
23
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
24
|
+
|
|
25
|
+
from mcpxray.extract.python_static import _PY_EXTS, _iter_source_files
|
|
26
|
+
from mcpxray.extract.typescript_static import _TS_EXTS
|
|
27
|
+
|
|
28
|
+
# Combined source extensions so scope detection narrows a TS repo the same way
|
|
29
|
+
# it already narrows a Python one (rather than scanning it wholesale).
|
|
30
|
+
_SOURCE_EXTS = _PY_EXTS + _TS_EXTS
|
|
31
|
+
|
|
32
|
+
# Hosts we treat as remote VCS URLs even without an http(s):// scheme.
|
|
33
|
+
_VCS_HOSTS = ("github.com", "gitlab.com", "bitbucket.org")
|
|
34
|
+
|
|
35
|
+
# Schemes ``git clone`` understands. ``file`` is included so tests (and local
|
|
36
|
+
# clones) work without touching the network.
|
|
37
|
+
_URL_SCHEMES = ("http", "https", "ssh", "git", "file")
|
|
38
|
+
|
|
39
|
+
# Defensive upper bound on a clone so a bad network can't hang CI/tests.
|
|
40
|
+
CLONE_TIMEOUT = 120
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SourceError(Exception):
|
|
44
|
+
"""Raised when a target cannot be resolved to a local path or manifest."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ResolvedSource:
|
|
49
|
+
"""Where to analyze from, plus an optional tempdir to clean up afterwards."""
|
|
50
|
+
|
|
51
|
+
path: Path | None # scan scope: the tree walked for source (may be a subdir)
|
|
52
|
+
root: Path | None # project root for pyproject/lockfiles (== path unless narrowed)
|
|
53
|
+
manifest: Path | None
|
|
54
|
+
cleanup: tempfile.TemporaryDirectory | None # set only when we cloned a URL
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_url(arg: str) -> bool:
|
|
58
|
+
"""True if ``arg`` looks like a remote VCS URL.
|
|
59
|
+
|
|
60
|
+
Covers ``http(s)://``, ``ssh://``, ``git://``, ``file://``, the SCP-style
|
|
61
|
+
``git@host:owner/repo``, and bare host prefixes (``github.com/owner/repo``).
|
|
62
|
+
"""
|
|
63
|
+
if "://" in arg:
|
|
64
|
+
return urlparse(arg).scheme.lower() in _URL_SCHEMES
|
|
65
|
+
if arg.startswith("git@"): # git@github.com:owner/repo.git
|
|
66
|
+
return True
|
|
67
|
+
return any(arg.startswith(host + "/") or arg.startswith(host + ":") for host in _VCS_HOSTS)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_url(arg: str) -> str:
|
|
71
|
+
"""Add an ``https://`` scheme to bare host URLs (``github.com/...``)."""
|
|
72
|
+
if "://" not in arg and not arg.startswith("git@"):
|
|
73
|
+
return f"https://{arg}"
|
|
74
|
+
return arg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clone(url: str) -> tuple[Path, tempfile.TemporaryDirectory]:
|
|
78
|
+
"""Shallow-clone ``url`` into a fresh temp directory; raise ``SourceError`` on failure."""
|
|
79
|
+
if not shutil.which("git"):
|
|
80
|
+
raise SourceError("git is not installed; install git or point mcpxray at a local path")
|
|
81
|
+
tmp = tempfile.TemporaryDirectory(prefix="mcpxray-")
|
|
82
|
+
# Clone into a subdir named after the repo so the verdict card shows a
|
|
83
|
+
# readable name ("python-sdk") instead of the tempdir's random suffix.
|
|
84
|
+
dest = Path(tmp.name) / _repo_name(url)
|
|
85
|
+
try:
|
|
86
|
+
subprocess.run(
|
|
87
|
+
["git", "clone", "--depth", "1", "--quiet", url, str(dest)],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=CLONE_TIMEOUT,
|
|
91
|
+
check=True,
|
|
92
|
+
)
|
|
93
|
+
except subprocess.CalledProcessError as e:
|
|
94
|
+
tmp.cleanup()
|
|
95
|
+
detail = (e.stderr or "").strip() or "unknown error"
|
|
96
|
+
raise SourceError(f"could not clone {url}: {detail}") from e
|
|
97
|
+
except subprocess.TimeoutExpired as e:
|
|
98
|
+
tmp.cleanup()
|
|
99
|
+
raise SourceError(f"timed out cloning {url} after {CLONE_TIMEOUT}s") from e
|
|
100
|
+
except Exception as e: # FileNotFoundError (git vanished), OSError, etc.
|
|
101
|
+
tmp.cleanup()
|
|
102
|
+
raise SourceError(f"could not clone {url}: {e}") from e
|
|
103
|
+
return dest, tmp
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _repo_name(url: str) -> str:
|
|
107
|
+
"""Last path segment of a URL, ``.git`` stripped: ``…/owner/repo.git`` → ``repo``."""
|
|
108
|
+
name = url.rstrip("/").split("/")[-1]
|
|
109
|
+
if name.endswith(".git"):
|
|
110
|
+
name = name[:-4]
|
|
111
|
+
return name or "repo"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- scope: locate the *server* source inside a tree -------------------------
|
|
115
|
+
|
|
116
|
+
# A cheap textual signal for an MCP tool registration/decorator. Catches the
|
|
117
|
+
# Python decorator (``@mcp.tool()``), the high-level registration call
|
|
118
|
+
# (``server.tool(...)`` / ``server.registerTool(...)``) used by both SDKs, and the
|
|
119
|
+
# low-level TS handler anchor (``ListToolsRequestSchema``) — good enough to bucket
|
|
120
|
+
# files by directory for the scope heuristic.
|
|
121
|
+
_TOOL_RE = re.compile(
|
|
122
|
+
r"\.\s*(?:tool|registerTool)\s*\(" r"|ListToolsRequestSchema" r"|^\s*@tool\b",
|
|
123
|
+
re.MULTILINE,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _has_source(path: Path) -> bool:
|
|
128
|
+
"""True if ``path`` contains any Python or TypeScript source (a coarse "is code" signal)."""
|
|
129
|
+
return any(True for _ in _iter_source_files(path, _SOURCE_EXTS))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _has_tool_call(path: Path) -> bool:
|
|
133
|
+
"""True if any source file under ``path`` registers an MCP tool."""
|
|
134
|
+
for p in _iter_source_files(path, _SOURCE_EXTS):
|
|
135
|
+
try:
|
|
136
|
+
if _TOOL_RE.search(p.read_text(encoding="utf-8")):
|
|
137
|
+
return True
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _entry_from_scripts(root: Path) -> Path | None:
|
|
144
|
+
"""Derive the server dir from ``[project.scripts]`` (only if it also has tools).
|
|
145
|
+
|
|
146
|
+
Maps each ``"pkg.mod:attr"`` entry point to ``root/src/<topseg>`` or
|
|
147
|
+
``root/<topseg>`` and returns the first existing dir that also registers a
|
|
148
|
+
tool — so a library's CLI entry point never narrows us into a tool-less dir.
|
|
149
|
+
"""
|
|
150
|
+
pyproject = root / "pyproject.toml"
|
|
151
|
+
if not pyproject.is_file():
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
155
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
156
|
+
return None
|
|
157
|
+
scripts = dict((data.get("project") or {}).get("scripts") or {})
|
|
158
|
+
scripts.update(((data.get("tool") or {}).get("poetry") or {}).get("scripts") or {})
|
|
159
|
+
for entry in scripts.values():
|
|
160
|
+
if not isinstance(entry, str) or ":" not in entry:
|
|
161
|
+
continue
|
|
162
|
+
top = entry.split(":", 1)[0].split(".")[0]
|
|
163
|
+
for cand in (root / "src" / top, root / top):
|
|
164
|
+
if cand.is_dir() and _has_tool_call(cand):
|
|
165
|
+
return cand
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _entry_from_tools(root: Path) -> Path | None:
|
|
170
|
+
"""The immediate subdirectory of ``root`` holding the most tool registrations."""
|
|
171
|
+
counts: dict[Path, int] = {}
|
|
172
|
+
for f in _iter_source_files(root, _SOURCE_EXTS):
|
|
173
|
+
try:
|
|
174
|
+
n = len(_TOOL_RE.findall(f.read_text(encoding="utf-8")))
|
|
175
|
+
except OSError:
|
|
176
|
+
continue
|
|
177
|
+
if n == 0:
|
|
178
|
+
continue
|
|
179
|
+
rel = f.relative_to(root).parts
|
|
180
|
+
if len(rel) < 2:
|
|
181
|
+
continue # file sits directly under root — narrowing would be a no-op
|
|
182
|
+
bucket = root / rel[0]
|
|
183
|
+
counts[bucket] = counts.get(bucket, 0) + n
|
|
184
|
+
if not counts:
|
|
185
|
+
return None
|
|
186
|
+
return max(counts, key=counts.get)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _detect_entry_dir(root: Path) -> Path:
|
|
190
|
+
"""Best-effort locate the server source dir within ``root``; fall back to ``root``.
|
|
191
|
+
|
|
192
|
+
Order: ``[project.scripts]`` entry point → the immediate subdir with the most
|
|
193
|
+
tool registrations → ``src/`` (if it holds any source) → ``root``. Only
|
|
194
|
+
narrows to a strict subdirectory on positive evidence, so an ambiguous tree is
|
|
195
|
+
scanned wholesale (current behaviour) rather than wrongly narrowed.
|
|
196
|
+
"""
|
|
197
|
+
return (
|
|
198
|
+
_entry_from_scripts(root)
|
|
199
|
+
or _entry_from_tools(root)
|
|
200
|
+
or ((root / "src") if (root / "src").is_dir() and _has_source(root / "src") else None)
|
|
201
|
+
or root
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _scope_path(root: Path, override: str | None) -> Path:
|
|
206
|
+
"""Resolve the scan scope within ``root``: an explicit override, else auto-detect."""
|
|
207
|
+
if override:
|
|
208
|
+
candidate = root / override
|
|
209
|
+
try:
|
|
210
|
+
candidate.resolve().relative_to(root.resolve())
|
|
211
|
+
except ValueError:
|
|
212
|
+
raise SourceError(f"--scope must stay under the target: {override!r}") from None
|
|
213
|
+
if not candidate.is_dir():
|
|
214
|
+
raise SourceError(f"--scope is not a directory under the target: {override!r}")
|
|
215
|
+
return candidate
|
|
216
|
+
return _detect_entry_dir(root)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def resolve_target(
|
|
220
|
+
arg: str | None, manifest: Path | None, scope: str | None = None
|
|
221
|
+
) -> ResolvedSource:
|
|
222
|
+
"""Resolve a CLI target into a local path/manifest (+ optional tempdir).
|
|
223
|
+
|
|
224
|
+
* ``manifest`` wins — pass it straight through (existence is validated later).
|
|
225
|
+
* ``None``/``"."`` → the current working directory (scanned literally, no scope).
|
|
226
|
+
* a URL → shallow-cloned into a temp directory (caller cleans ``cleanup`` up),
|
|
227
|
+
then narrowed to the server source dir unless ``scope`` says otherwise.
|
|
228
|
+
* anything else → treated as a local path (must exist); a directory is narrowed
|
|
229
|
+
to the server source dir unless ``scope`` says otherwise.
|
|
230
|
+
|
|
231
|
+
``ResolvedSource.path`` is the *scan scope* (possibly a subdir of ``root``);
|
|
232
|
+
``ResolvedSource.root`` is the project root used for ``pyproject.toml`` /
|
|
233
|
+
lockfiles, so dependency checks survive scope narrowing.
|
|
234
|
+
"""
|
|
235
|
+
if manifest is not None:
|
|
236
|
+
return ResolvedSource(path=None, root=None, manifest=manifest, cleanup=None)
|
|
237
|
+
|
|
238
|
+
if arg is None or arg == ".":
|
|
239
|
+
cwd = Path.cwd()
|
|
240
|
+
return ResolvedSource(path=cwd, root=cwd, manifest=None, cleanup=None)
|
|
241
|
+
|
|
242
|
+
if is_url(arg):
|
|
243
|
+
clone_root, cleanup = _clone(_normalize_url(arg))
|
|
244
|
+
scope_dir = _scope_path(clone_root, scope) if clone_root.is_dir() else clone_root
|
|
245
|
+
return ResolvedSource(path=scope_dir, root=clone_root, manifest=None, cleanup=cleanup)
|
|
246
|
+
|
|
247
|
+
local = Path(arg)
|
|
248
|
+
if not local.exists():
|
|
249
|
+
raise SourceError(f"path not found: {arg}")
|
|
250
|
+
if local.is_dir():
|
|
251
|
+
scope_dir = _scope_path(local, scope)
|
|
252
|
+
return ResolvedSource(path=scope_dir, root=local, manifest=None, cleanup=None)
|
|
253
|
+
return ResolvedSource(path=local, root=local, manifest=None, cleanup=None)
|