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 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
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m mcpxray`."""
2
+
3
+ from mcpxray.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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"]
@@ -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))