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/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""mcpxray — static linter + 0-100 scorecard for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
# The installed distribution is `mcpxray-cli` (the PyPI name; `mcpxray` is
|
|
6
|
+
# taken there by an unrelated project) — the import package is `mcpxray`.
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("mcpxray-cli")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("mcpxray") # pre-rename installs
|
|
12
|
+
except PackageNotFoundError: # source tree that isn't installed
|
|
13
|
+
__version__ = "0.0.0"
|
|
14
|
+
|
|
15
|
+
# The top-level package surface is intentionally tiny (just the version). The
|
|
16
|
+
# stable plugin API lives at its submodule paths — see CONTRIBUTING.md →
|
|
17
|
+
# "Plugin API stability" for the full contract.
|
|
18
|
+
__all__ = ["__version__"]
|
mcpxray/__main__.py
ADDED
mcpxray/badge.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Score badge as an SVG (shields-style, embeddable in a README)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcpxray.score import ScoreResult
|
|
6
|
+
|
|
7
|
+
_GRADE_COLOR = {
|
|
8
|
+
"A": "#4c1", # brightgreen
|
|
9
|
+
"B": "#97ca00", # green
|
|
10
|
+
"C": "#dfb317", # yellow
|
|
11
|
+
"D": "#fe7d37", # orange
|
|
12
|
+
"F": "#e05d44", # red
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_HEIGHT = 20
|
|
16
|
+
_FONT = "Verdana, 'DejaVu Sans', sans-serif"
|
|
17
|
+
_FONT_SIZE = 11
|
|
18
|
+
_CHAR_WIDTH = 6.2 # approx average advance for the badge font at size 11
|
|
19
|
+
_PAD = 6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _text_width(text: str) -> int:
|
|
23
|
+
return int(len(text) * _CHAR_WIDTH) + 2 * _PAD
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def badge_svg(score_result: ScoreResult, *, label: str = "mcp score") -> str:
|
|
27
|
+
"""Render a flat SVG badge for the given score."""
|
|
28
|
+
color = _GRADE_COLOR[score_result.grade]
|
|
29
|
+
value = f"{score_result.score}/100"
|
|
30
|
+
label_w = _text_width(label)
|
|
31
|
+
value_w = _text_width(value)
|
|
32
|
+
total_w = label_w + value_w
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
f'<svg xmlns="http://www.w3.org/2000/svg"'
|
|
36
|
+
f' width="{total_w}" height="{_HEIGHT}"'
|
|
37
|
+
f' role="img" aria-label="{label}: {value}">'
|
|
38
|
+
f'<linearGradient id="s" x2="0" y2="100%">'
|
|
39
|
+
f'<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
|
|
40
|
+
f'<stop offset="1" stop-opacity=".1"/></linearGradient>'
|
|
41
|
+
f'<rect width="{total_w}" height="{_HEIGHT}" fill="#555"/>'
|
|
42
|
+
f'<rect x="{label_w}" width="{value_w}" height="{_HEIGHT}" fill="{color}"/>'
|
|
43
|
+
f'<rect width="{total_w}" height="{_HEIGHT}" fill="url(#s)"/>'
|
|
44
|
+
f'<text x="{_PAD}" y="14" fill="#fff" font-family="{_FONT}" font-size="{_FONT_SIZE}">'
|
|
45
|
+
f"{label}</text>"
|
|
46
|
+
f'<text x="{label_w + _PAD}" y="14" fill="#fff" font-family="{_FONT}"'
|
|
47
|
+
f' font-size="{_FONT_SIZE}">{value}</text>'
|
|
48
|
+
f"</svg>"
|
|
49
|
+
)
|
mcpxray/cli.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""mcpxray CLI — `scan`, `score`, `badge`, `version`.
|
|
2
|
+
|
|
3
|
+
The CLI is a thin shell over the extractor → rule-engine → score → render
|
|
4
|
+
pipeline. `scan` lints and reports; `score` collapses to a 0-100 number;
|
|
5
|
+
`badge` emits an SVG; `version` prints the version.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from mcpxray import __version__
|
|
16
|
+
from mcpxray.badge import badge_svg
|
|
17
|
+
from mcpxray.extract import extractor_for
|
|
18
|
+
from mcpxray.extract.manifest import ManifestExtractor
|
|
19
|
+
from mcpxray.extract.manifest import from_tools as manifest_from_tools
|
|
20
|
+
from mcpxray.fix import apply_fixes, has_pending, plan_fixes, render_diff
|
|
21
|
+
from mcpxray.ir import SEVERITY_ERROR, SOURCE_STATIC, McpServer, ServerMeta
|
|
22
|
+
from mcpxray.report import SUPPORTED_FORMATS, render
|
|
23
|
+
from mcpxray.report.card import render_verdict
|
|
24
|
+
from mcpxray.rules import run_all
|
|
25
|
+
from mcpxray.runtime import CaptureError, capture_tools, split_command
|
|
26
|
+
from mcpxray.score import ScoreResult
|
|
27
|
+
from mcpxray.score import score as score_doc
|
|
28
|
+
from mcpxray.source import ResolvedSource, SourceError, is_url, resolve_target
|
|
29
|
+
from mcpxray.verdict import verdict
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="mcpxray",
|
|
33
|
+
help="Static linter + 0-100 scorecard for MCP servers.",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_doc(
|
|
40
|
+
resolved: ResolvedSource, runtime_argv: list[str] | None, *, graceful: bool
|
|
41
|
+
) -> McpServer:
|
|
42
|
+
"""Build a :class:`McpServer` from a manifest file, a runtime capture, or source.
|
|
43
|
+
|
|
44
|
+
The three input modes are mutually exclusive (enforced in :func:`_run`).
|
|
45
|
+
``graceful`` only affects the static no-extractor case: ``check`` synthesizes
|
|
46
|
+
an empty static doc (→ an honest UNKNOWN card); ``scan``/``score`` exit-2.
|
|
47
|
+
"""
|
|
48
|
+
if resolved.manifest is not None:
|
|
49
|
+
if not resolved.manifest.is_file():
|
|
50
|
+
typer.echo(f"error: manifest not found: {resolved.manifest}", err=True)
|
|
51
|
+
raise typer.Exit(code=2)
|
|
52
|
+
return ManifestExtractor().extract(resolved.manifest)
|
|
53
|
+
|
|
54
|
+
if runtime_argv is not None:
|
|
55
|
+
captured = capture_tools(runtime_argv, cwd=resolved.path)
|
|
56
|
+
return manifest_from_tools(
|
|
57
|
+
captured.tools,
|
|
58
|
+
name=captured.server_name or "runtime",
|
|
59
|
+
path_str=str(resolved.path or resolved.root or Path.cwd()),
|
|
60
|
+
version=captured.server_version,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
path = resolved.path if resolved.path is not None else Path.cwd()
|
|
64
|
+
extractor = extractor_for(path)
|
|
65
|
+
if extractor is None:
|
|
66
|
+
if graceful:
|
|
67
|
+
return McpServer(
|
|
68
|
+
meta=ServerMeta(name=path.name, language=None, path=str(resolved.root or path)),
|
|
69
|
+
source_mode=SOURCE_STATIC,
|
|
70
|
+
)
|
|
71
|
+
typer.echo(
|
|
72
|
+
f"error: no extractor matched {path} "
|
|
73
|
+
"(point at a source tree, pass --manifest <tools/list.json>, "
|
|
74
|
+
"or use --runtime --command '<launch>')",
|
|
75
|
+
err=True,
|
|
76
|
+
)
|
|
77
|
+
raise typer.Exit(code=2)
|
|
78
|
+
return extractor.extract(path, root=resolved.root)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _analyze(
|
|
82
|
+
resolved: ResolvedSource, runtime_argv: list[str] | None, *, graceful: bool
|
|
83
|
+
) -> tuple[McpServer, ScoreResult]:
|
|
84
|
+
"""Extract, run all rules, score. Returns (doc, score_result)."""
|
|
85
|
+
doc = _extract_doc(resolved, runtime_argv, graceful=graceful)
|
|
86
|
+
run_all(doc)
|
|
87
|
+
return doc, score_doc(doc)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run(
|
|
91
|
+
target: str | None,
|
|
92
|
+
manifest: Path | None,
|
|
93
|
+
scope: str | None,
|
|
94
|
+
runtime: bool,
|
|
95
|
+
command: str | None,
|
|
96
|
+
*,
|
|
97
|
+
graceful: bool,
|
|
98
|
+
) -> tuple[McpServer, ScoreResult]:
|
|
99
|
+
"""Resolve a target (URL / path / manifest, optionally scoped) and analyze it.
|
|
100
|
+
|
|
101
|
+
Shared by every command so URL input, ``--scope``, ``--runtime``, and tempdir
|
|
102
|
+
cleanup live in one place. ``graceful`` selects UNKNOWN-on-no-extractor
|
|
103
|
+
(``check``) vs the exit-2 that ``scan``/``score``/``badge`` raise.
|
|
104
|
+
"""
|
|
105
|
+
if manifest is not None and runtime:
|
|
106
|
+
typer.echo("error: --manifest and --runtime are mutually exclusive", err=True)
|
|
107
|
+
raise typer.Exit(code=2)
|
|
108
|
+
if runtime and not command:
|
|
109
|
+
typer.echo("error: --runtime needs --command <launch cmd>", err=True)
|
|
110
|
+
raise typer.Exit(code=2)
|
|
111
|
+
|
|
112
|
+
resolved: ResolvedSource | None = None
|
|
113
|
+
try:
|
|
114
|
+
runtime_argv = split_command(command) if runtime else None
|
|
115
|
+
resolved = resolve_target(target, manifest, scope)
|
|
116
|
+
return _analyze(resolved, runtime_argv, graceful=graceful)
|
|
117
|
+
except CaptureError as e:
|
|
118
|
+
typer.echo(f"error: runtime capture failed: {e}", err=True)
|
|
119
|
+
raise typer.Exit(code=2) from None
|
|
120
|
+
finally:
|
|
121
|
+
if resolved is not None and resolved.cleanup is not None:
|
|
122
|
+
try:
|
|
123
|
+
resolved.cleanup.cleanup()
|
|
124
|
+
except OSError: # git may briefly hold pack-locks on Windows
|
|
125
|
+
shutil.rmtree(resolved.cleanup.name, ignore_errors=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def check(
|
|
130
|
+
target: str = typer.Argument(None, help="GitHub URL or local path to the MCP server source."),
|
|
131
|
+
manifest: Path = typer.Option(
|
|
132
|
+
None, "--manifest", help="Captured tools/list JSON dump instead of source/URL."
|
|
133
|
+
),
|
|
134
|
+
runtime: bool = typer.Option(
|
|
135
|
+
False,
|
|
136
|
+
"--runtime",
|
|
137
|
+
help=(
|
|
138
|
+
"Spawn the server and capture its tools/list over MCP stdio (opt-in; "
|
|
139
|
+
"needs --command; executes the server — trusted/container only)."
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
command: str = typer.Option(
|
|
143
|
+
None,
|
|
144
|
+
"--command",
|
|
145
|
+
help="Launch command for --runtime, e.g. 'python -m srv' / 'node server.js' (POSIX split).",
|
|
146
|
+
),
|
|
147
|
+
scope: str = typer.Option(
|
|
148
|
+
None, "--scope", help="Subdirectory to scan/run in, relative to the target."
|
|
149
|
+
),
|
|
150
|
+
details: bool = typer.Option(
|
|
151
|
+
False, "--details", "-v", help="Also print the full finding list."
|
|
152
|
+
),
|
|
153
|
+
fail_under: int = typer.Option(
|
|
154
|
+
0, "--fail-under", help="Gate: exit 1 if the score is below N (CI mode)."
|
|
155
|
+
),
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Friendly safety check: is this MCP server safe to install?"""
|
|
158
|
+
try:
|
|
159
|
+
doc, score_result = _run(target, manifest, scope, runtime, command, graceful=True)
|
|
160
|
+
except SourceError as e:
|
|
161
|
+
typer.echo(f"error: {e}", err=True)
|
|
162
|
+
raise typer.Exit(code=2) from None
|
|
163
|
+
|
|
164
|
+
v = verdict(doc, score_result)
|
|
165
|
+
typer.echo(render_verdict(v, doc=doc, score_result=score_result, details=details))
|
|
166
|
+
|
|
167
|
+
if v.tier == "danger" or not score_result.passed(fail_under):
|
|
168
|
+
raise typer.Exit(code=1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command()
|
|
172
|
+
def version() -> None:
|
|
173
|
+
"""Print the mcpxray version."""
|
|
174
|
+
typer.echo(__version__)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def scan(
|
|
179
|
+
target: str = typer.Argument(None, help="Path or URL to the MCP server source."),
|
|
180
|
+
manifest: Path = typer.Option(
|
|
181
|
+
None, "--manifest", help="Captured tools/list JSON dump instead of source."
|
|
182
|
+
),
|
|
183
|
+
runtime: bool = typer.Option(
|
|
184
|
+
False,
|
|
185
|
+
"--runtime",
|
|
186
|
+
help=(
|
|
187
|
+
"Spawn the server and capture its tools/list over MCP stdio (opt-in; needs --command)."
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
command: str = typer.Option(
|
|
191
|
+
None,
|
|
192
|
+
"--command",
|
|
193
|
+
help="Launch command for --runtime, e.g. 'python -m srv' / 'node server.js'.",
|
|
194
|
+
),
|
|
195
|
+
scope: str = typer.Option(
|
|
196
|
+
None, "--scope", help="Subdirectory to scan/run in, relative to the target."
|
|
197
|
+
),
|
|
198
|
+
fmt: str = typer.Option(
|
|
199
|
+
"plain", "-f", "--format", help=f"Report format: {', '.join(SUPPORTED_FORMATS)}."
|
|
200
|
+
),
|
|
201
|
+
check: bool = typer.Option(
|
|
202
|
+
False, "--check", help="Gate: exit 1 on any ERROR finding (CI mode)."
|
|
203
|
+
),
|
|
204
|
+
fix: bool = typer.Option(
|
|
205
|
+
False,
|
|
206
|
+
"--fix",
|
|
207
|
+
help="Apply auto-fixes in place (unpinned deps → pinned). Local source only.",
|
|
208
|
+
),
|
|
209
|
+
diff: bool = typer.Option(
|
|
210
|
+
False,
|
|
211
|
+
"--diff",
|
|
212
|
+
help="Print a unified diff of planned fixes; write nothing (exits 1 if any pending).",
|
|
213
|
+
),
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Lint an MCP server and print findings in the chosen format."""
|
|
216
|
+
if fix or diff:
|
|
217
|
+
if fix and diff:
|
|
218
|
+
typer.echo("error: --fix and --diff are mutually exclusive", err=True)
|
|
219
|
+
raise typer.Exit(code=2)
|
|
220
|
+
if manifest is not None or runtime:
|
|
221
|
+
typer.echo(
|
|
222
|
+
"error: --fix/--diff rewrite source files — use a local path, "
|
|
223
|
+
"not --manifest/--runtime",
|
|
224
|
+
err=True,
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(code=2)
|
|
227
|
+
if target is not None and is_url(target):
|
|
228
|
+
typer.echo(
|
|
229
|
+
"error: --fix/--diff rewrite files in place — point at a local path, not a URL",
|
|
230
|
+
err=True,
|
|
231
|
+
)
|
|
232
|
+
raise typer.Exit(code=2)
|
|
233
|
+
try:
|
|
234
|
+
doc, score_result = _run(target, manifest, scope, runtime, command, graceful=False)
|
|
235
|
+
except SourceError as e:
|
|
236
|
+
typer.echo(f"error: {e}", err=True)
|
|
237
|
+
raise typer.Exit(code=2) from None
|
|
238
|
+
|
|
239
|
+
if fix or diff:
|
|
240
|
+
fixes = plan_fixes(doc)
|
|
241
|
+
if diff:
|
|
242
|
+
out = render_diff(fixes)
|
|
243
|
+
if out:
|
|
244
|
+
typer.echo(out, nl=False)
|
|
245
|
+
if has_pending(fixes):
|
|
246
|
+
raise typer.Exit(code=1)
|
|
247
|
+
return
|
|
248
|
+
summary = apply_fixes(fixes)
|
|
249
|
+
typer.echo(f"applied {summary.edits_applied} fix(es) in {summary.files_changed} file(s)")
|
|
250
|
+
for msg in summary.skipped:
|
|
251
|
+
typer.echo(f"skipped: {msg}", err=True)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
typer.echo(render(doc.diagnostics, fmt, doc=doc, score_result=score_result))
|
|
255
|
+
if check and any(d.severity == SEVERITY_ERROR for d in doc.diagnostics):
|
|
256
|
+
raise typer.Exit(code=1)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
def score(
|
|
261
|
+
target: str = typer.Argument(None, help="Path or URL to the MCP server source."),
|
|
262
|
+
manifest: Path = typer.Option(None, "--manifest", help="Captured tools/list JSON dump."),
|
|
263
|
+
runtime: bool = typer.Option(
|
|
264
|
+
False,
|
|
265
|
+
"--runtime",
|
|
266
|
+
help=(
|
|
267
|
+
"Spawn the server and capture its tools/list over MCP stdio (opt-in; needs --command)."
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
command: str = typer.Option(
|
|
271
|
+
None,
|
|
272
|
+
"--command",
|
|
273
|
+
help="Launch command for --runtime, e.g. 'python -m srv' / 'node server.js'.",
|
|
274
|
+
),
|
|
275
|
+
scope: str = typer.Option(
|
|
276
|
+
None, "--scope", help="Subdirectory to scan/run in, relative to the target."
|
|
277
|
+
),
|
|
278
|
+
fail_under: int = typer.Option(0, "--fail-under", help="Gate: exit 1 if the score is below N."),
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Print the 0-100 score and grade; exit 1 if below --fail-under."""
|
|
281
|
+
try:
|
|
282
|
+
_doc, score_result = _run(target, manifest, scope, runtime, command, graceful=False)
|
|
283
|
+
except SourceError as e:
|
|
284
|
+
typer.echo(f"error: {e}", err=True)
|
|
285
|
+
raise typer.Exit(code=2) from None
|
|
286
|
+
cap = " [capped by error finding]" if score_result.capped else ""
|
|
287
|
+
typer.echo(f"score {score_result.score}/100 (grade {score_result.grade}){cap}")
|
|
288
|
+
if not score_result.passed(fail_under):
|
|
289
|
+
raise typer.Exit(code=1)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.command()
|
|
293
|
+
def badge(
|
|
294
|
+
target: str = typer.Argument(None, help="Path or URL to the MCP server source to score."),
|
|
295
|
+
score_value: int = typer.Option(None, "--score", help="Render a literal score (0-100)."),
|
|
296
|
+
output: Path = typer.Option(
|
|
297
|
+
Path("badge.svg"), "-o", "--output", help="Output SVG path ('-' for stdout)."
|
|
298
|
+
),
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Render a score badge as SVG (from a path/URL, or a literal --score)."""
|
|
301
|
+
if score_value is not None:
|
|
302
|
+
result = ScoreResult(score=score_value, errors=0, warnings=0, infos=0, capped=False)
|
|
303
|
+
elif target is not None:
|
|
304
|
+
try:
|
|
305
|
+
_doc, result = _run(target, None, None, False, None, graceful=False)
|
|
306
|
+
except SourceError as e:
|
|
307
|
+
typer.echo(f"error: {e}", err=True)
|
|
308
|
+
raise typer.Exit(code=2) from None
|
|
309
|
+
else:
|
|
310
|
+
typer.echo("error: provide a PATH or --score N", err=True)
|
|
311
|
+
raise typer.Exit(code=2)
|
|
312
|
+
|
|
313
|
+
svg = badge_svg(result)
|
|
314
|
+
if str(output) == "-":
|
|
315
|
+
typer.echo(svg)
|
|
316
|
+
else:
|
|
317
|
+
output.write_text(svg, encoding="utf-8")
|
|
318
|
+
typer.echo(f"wrote {output}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
app()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Extractors turn a path (source tree or manifest) into a :class:`McpServer` IR."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Importing the builtin extractors triggers @register_extractor self-registration.
|
|
6
|
+
from mcpxray.extract import manifest as _manifest # noqa: F401
|
|
7
|
+
from mcpxray.extract import python_static as _python_static # noqa: F401
|
|
8
|
+
from mcpxray.extract import typescript_static as _typescript_static # noqa: F401
|
|
9
|
+
from mcpxray.extract.base import (
|
|
10
|
+
Extractor,
|
|
11
|
+
extractor_for,
|
|
12
|
+
extractors,
|
|
13
|
+
register_extractor,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = ["Extractor", "extractor_for", "extractors", "register_extractor"]
|
mcpxray/extract/base.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Extractor API + registry.
|
|
2
|
+
|
|
3
|
+
An :class:`Extractor` reads a path (a source tree or a manifest file) and emits
|
|
4
|
+
a :class:`~mcpxray.ir.McpServer`. Builtins self-register via
|
|
5
|
+
:func:`register_extractor`; external packages declare an entry-point in the
|
|
6
|
+
``mcpxray.extractors`` group (loaded in v0.1 alongside rule entry-points).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from mcpxray.ir import McpServer
|
|
15
|
+
|
|
16
|
+
# Stable plugin API for extractor authors (see CONTRIBUTING.md → "Plugin API
|
|
17
|
+
# stability"). ``_EXTRACTORS`` and any ``_``-prefixed name are internal.
|
|
18
|
+
__all__ = ["Extractor", "register_extractor", "extractors", "extractor_for"]
|
|
19
|
+
|
|
20
|
+
_EXTRACTORS: list[type[Extractor]] = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Extractor(ABC):
|
|
24
|
+
"""Base class for all extractors."""
|
|
25
|
+
|
|
26
|
+
language: str = "" # "python" | "typescript" | "" (manifest)
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def applies_to(self, path: Path) -> bool:
|
|
30
|
+
"""True if this extractor can read ``path`` (file or directory)."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def extract(self, path: Path, *, root: Path | None = None) -> McpServer:
|
|
34
|
+
"""Build a :class:`McpServer` from ``path``.
|
|
35
|
+
|
|
36
|
+
``path`` is the *scan scope* — the tree walked for source files. ``root``,
|
|
37
|
+
when given, is the wider *project root* to read ``pyproject.toml`` /
|
|
38
|
+
lockfiles from (it differs from ``path`` only when the caller has narrowed
|
|
39
|
+
the scan to a subpackage). Defaults to ``path`` so unscoped calls behave
|
|
40
|
+
exactly as before.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register_extractor(cls: type[Extractor]) -> type[Extractor]:
|
|
45
|
+
"""Class decorator: register an :class:`Extractor` subclass."""
|
|
46
|
+
_EXTRACTORS.append(cls)
|
|
47
|
+
return cls
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extractors() -> list[type[Extractor]]:
|
|
51
|
+
"""All registered extractor classes (builtins first, registration order)."""
|
|
52
|
+
return list(_EXTRACTORS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extractor_for(path: Path) -> Extractor | None:
|
|
56
|
+
"""Return the first registered extractor whose ``applies_to`` matches."""
|
|
57
|
+
for cls in _EXTRACTORS:
|
|
58
|
+
ext = cls()
|
|
59
|
+
if ext.applies_to(path):
|
|
60
|
+
return ext
|
|
61
|
+
return None
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Manifest extractor — parse a captured ``tools/list`` JSON dump.
|
|
2
|
+
|
|
3
|
+
For servers whose source we can't (or don't want to) parse — any language,
|
|
4
|
+
compiled, or third-party — a user captures ``tools/list`` and feeds the JSON
|
|
5
|
+
here. Tools learned this way carry ``runtime_only=True``.
|
|
6
|
+
|
|
7
|
+
The per-tool construction (:func:`_tool_from_entry`) and the
|
|
8
|
+
:class:`~mcpxray.ir.McpServer` assembly (:func:`_build`) are shared with the
|
|
9
|
+
runtime capture path (:mod:`mcpxray.runtime` → :func:`from_tools`), so a
|
|
10
|
+
hand-fed manifest file and a live ``tools/list`` capture produce the same IR.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from mcpxray.extract.base import Extractor, register_extractor
|
|
19
|
+
from mcpxray.ir import SOURCE_MANIFEST, SOURCE_RUNTIME, McpServer, ServerMeta, Tool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_tools(payload: object) -> list[dict]:
|
|
23
|
+
"""Locate the tools list in either a raw or JSON-RPC-wrapped response."""
|
|
24
|
+
if isinstance(payload, dict):
|
|
25
|
+
if isinstance(payload.get("tools"), list):
|
|
26
|
+
return payload["tools"]
|
|
27
|
+
result = payload.get("result")
|
|
28
|
+
if isinstance(result, dict) and isinstance(result.get("tools"), list):
|
|
29
|
+
return result["tools"]
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _looks_like_manifest(path: Path) -> bool:
|
|
34
|
+
if not path.is_file() or path.suffix != ".json":
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except (json.JSONDecodeError, OSError):
|
|
39
|
+
return False
|
|
40
|
+
return bool(_find_tools(payload))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tool_from_entry(entry: object) -> Tool | None:
|
|
44
|
+
"""Build a single ``runtime_only`` tool from a tools/list entry, or skip it."""
|
|
45
|
+
if not isinstance(entry, dict) or not entry.get("name"):
|
|
46
|
+
return None
|
|
47
|
+
return Tool(
|
|
48
|
+
name=entry["name"],
|
|
49
|
+
description=entry.get("description"),
|
|
50
|
+
input_schema=entry.get("inputSchema") or entry.get("input_schema") or {},
|
|
51
|
+
runtime_only=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build(
|
|
56
|
+
tools: list[dict],
|
|
57
|
+
*,
|
|
58
|
+
name: str,
|
|
59
|
+
path_str: str,
|
|
60
|
+
version: str | None = None,
|
|
61
|
+
source_mode: str = SOURCE_MANIFEST,
|
|
62
|
+
) -> McpServer:
|
|
63
|
+
"""Assemble a runtime-only :class:`McpServer` from a tools/list list."""
|
|
64
|
+
server = McpServer(
|
|
65
|
+
meta=ServerMeta(name=name, version=version, language=None, path=path_str),
|
|
66
|
+
source_mode=source_mode,
|
|
67
|
+
)
|
|
68
|
+
for entry in tools:
|
|
69
|
+
tool = _tool_from_entry(entry)
|
|
70
|
+
if tool is not None:
|
|
71
|
+
server.tools.append(tool)
|
|
72
|
+
return server
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def from_tools(
|
|
76
|
+
tools: list[dict],
|
|
77
|
+
*,
|
|
78
|
+
name: str = "runtime",
|
|
79
|
+
path_str: str = "<runtime>",
|
|
80
|
+
version: str | None = None,
|
|
81
|
+
) -> McpServer:
|
|
82
|
+
"""Build an IR from a captured ``tools/list`` tool list (runtime path)."""
|
|
83
|
+
return _build(tools, name=name, path_str=path_str, version=version, source_mode=SOURCE_RUNTIME)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@register_extractor
|
|
87
|
+
class ManifestExtractor(Extractor):
|
|
88
|
+
"""Extract tools from a captured ``tools/list`` JSON dump."""
|
|
89
|
+
|
|
90
|
+
language = "" # language-agnostic
|
|
91
|
+
|
|
92
|
+
def applies_to(self, path: Path) -> bool:
|
|
93
|
+
return _looks_like_manifest(path)
|
|
94
|
+
|
|
95
|
+
def extract(self, path: Path, *, root: Path | None = None) -> McpServer:
|
|
96
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
97
|
+
return _build(_find_tools(payload), name=path.stem, path_str=str(path))
|