mcpxray-cli 1.0.0__tar.gz

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.
Files changed (33) hide show
  1. mcpxray_cli-1.0.0/PKG-INFO +211 -0
  2. mcpxray_cli-1.0.0/README.md +199 -0
  3. mcpxray_cli-1.0.0/pyproject.toml +59 -0
  4. mcpxray_cli-1.0.0/pyproject.toml.orig +59 -0
  5. mcpxray_cli-1.0.0/src/mcpxray/__init__.py +18 -0
  6. mcpxray_cli-1.0.0/src/mcpxray/__main__.py +6 -0
  7. mcpxray_cli-1.0.0/src/mcpxray/badge.py +49 -0
  8. mcpxray_cli-1.0.0/src/mcpxray/cli.py +322 -0
  9. mcpxray_cli-1.0.0/src/mcpxray/extract/__init__.py +16 -0
  10. mcpxray_cli-1.0.0/src/mcpxray/extract/base.py +61 -0
  11. mcpxray_cli-1.0.0/src/mcpxray/extract/manifest.py +97 -0
  12. mcpxray_cli-1.0.0/src/mcpxray/extract/python_static.py +316 -0
  13. mcpxray_cli-1.0.0/src/mcpxray/extract/typescript_static.py +552 -0
  14. mcpxray_cli-1.0.0/src/mcpxray/fix.py +166 -0
  15. mcpxray_cli-1.0.0/src/mcpxray/ir.py +186 -0
  16. mcpxray_cli-1.0.0/src/mcpxray/report/__init__.py +47 -0
  17. mcpxray_cli-1.0.0/src/mcpxray/report/card.py +137 -0
  18. mcpxray_cli-1.0.0/src/mcpxray/report/github.py +35 -0
  19. mcpxray_cli-1.0.0/src/mcpxray/report/json.py +50 -0
  20. mcpxray_cli-1.0.0/src/mcpxray/report/plain.py +35 -0
  21. mcpxray_cli-1.0.0/src/mcpxray/report/sarif.py +76 -0
  22. mcpxray_cli-1.0.0/src/mcpxray/rules/__init__.py +8 -0
  23. mcpxray_cli-1.0.0/src/mcpxray/rules/base.py +67 -0
  24. mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/__init__.py +5 -0
  25. mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/descriptions.py +103 -0
  26. mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/schema.py +115 -0
  27. mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/source.py +95 -0
  28. mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/supply.py +92 -0
  29. mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/transport.py +79 -0
  30. mcpxray_cli-1.0.0/src/mcpxray/runtime.py +260 -0
  31. mcpxray_cli-1.0.0/src/mcpxray/score.py +71 -0
  32. mcpxray_cli-1.0.0/src/mcpxray/source.py +253 -0
  33. mcpxray_cli-1.0.0/src/mcpxray/verdict.py +139 -0
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcpxray-cli
3
+ Version: 1.0.0
4
+ Summary: Static linter + 0-100 scorecard for MCP servers. Catches tool poisoning, secret leaks, schema drift and more — locally, deterministically, in CI.
5
+ Author: Mitia
6
+ Author-email: Mitia <mitia@cloudroad.io>
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: pyyaml>=6
9
+ Requires-Dist: tomli>=2 ; python_full_version < '3.11'
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+
13
+ # mcpxray
14
+
15
+ **Static linter + 0–100 scorecard for MCP servers.** `mcpxray` scans an MCP server's source (or a captured `tools/list` manifest) and flags tool poisoning, leaked secrets, dangerous capabilities, weak schemas and more — **locally, deterministically, in CI**. Point it at a GitHub URL or a local path and get a plain-language verdict — 🟢 ok / 🟡 caution / 🔴 danger — plus a 0–100 score and an SVG badge.
16
+
17
+ > MCP is the fastest-growing dev protocol since GraphQL (~97M SDK downloads/month), yet 7%+ of servers ship with vulnerabilities and the OWASP MCP Top 10 is a list, not a tool. `mcpxray` is the missing `npm audit` + OpenSSF Scorecard for MCP — static, local-first, OSS.
18
+
19
+ ## Status
20
+
21
+ ✅ **v1.0.0** — stable public API. Python + TypeScript static extractors, manifest extractor, opt-in runtime `tools/list` capture, rules **MCP101–109** (full OWASP MCP Top-10 mapping), `check`/`scan`/`score`/`badge`/`version`, plain/json/github/sarif/card reports, 0–100 score with error cap, SVG badge, `--fix`/`--diff` for MCP108, pre-commit hook, frozen plugin API (`__all__` + SemVer policy), tokenless PyPI trusted publishing. 224 tests.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ uv tool install mcpxray-cli
27
+ # or: pip install mcpxray-cli
28
+ ```
29
+
30
+ The PyPI distribution is `mcpxray-cli` (the name `mcpxray` is blocked on PyPI by an unrelated project); the command it installs is still `mcpxray`.
31
+
32
+ Requires Python ≥ 3.10.
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ # Is this MCP server safe to install? Point mcpxray at a GitHub URL or a local path.
38
+ mcpxray check https://github.com/owner/repo
39
+ mcpxray check path/to/my-mcp-server
40
+
41
+ # Verdict + the full finding list
42
+ mcpxray check path/to/my-mcp-server --details
43
+
44
+ # Not a Python server? Hand mcpxray a captured tools/list dump (any language)
45
+ mcpxray check --manifest tools-list.json
46
+
47
+ # ...or spawn the server and let mcpxray capture tools/list live (any language)
48
+ mcpxray check --runtime --command "python -m my_mcp_server"
49
+
50
+ # --- power users / CI -----------------------------------------------------
51
+ # Lint and print findings (CI gate: --check exits 1 on any ERROR)
52
+ mcpxray scan path/to/my-mcp-server --check -f github
53
+ # Just the 0–100 score, fail below a bar
54
+ mcpxray score path/to/my-mcp-server --fail-under 80
55
+ # Embed an SVG score badge in your README
56
+ mcpxray badge path/to/my-mcp-server -o docs/score.svg
57
+ ```
58
+
59
+ ## Commands
60
+
61
+ | Command | Purpose |
62
+ | --- | --- |
63
+ | `mcpxray check <URL \| PATH> [--manifest FILE] [--runtime --command CMD] [--details\|-v] [--fail-under N]` | **Friendly safety verdict** (🟢/🟡/🔴/⚪) + recommendation. Clones a GitHub URL automatically; exits 1 on 🔴 danger or below `--fail-under`. |
64
+ | `mcpxray scan [PATH] [--manifest FILE] [--runtime --command CMD] [-f plain\|json\|github\|sarif\|card] [--check] [--fix] [--diff]` | Lint a server and print findings. `--check` exits 1 on any ERROR (CI gate); `--fix` pins unpinned deps in place, `--diff` previews (local source only). |
65
+ | `mcpxray score [PATH] [--manifest FILE] [--runtime --command CMD] [--fail-under N]` | Print the 0–100 score and grade; exit 1 below `--fail-under`. |
66
+ | `mcpxray badge [PATH \| --score N] [-o FILE]` | Render an SVG score badge (`-o -` for stdout). |
67
+ | `mcpxray version` | Print the version. |
68
+
69
+ ## What the verdict means
70
+
71
+ `mcpxray check` turns findings into a traffic-light verdict instead of a raw score:
72
+
73
+ | Verdict | When | Exit | What to do |
74
+ | --- | --- | --- | --- |
75
+ | 🟢 **OK** | nothing found | 0 | Safe to add to Claude Code. |
76
+ | 🟡 **CAUTION** | no errors, but warnings (weak schemas, unpinned deps, …) | 0 | Usable — mind the listed weaknesses. |
77
+ | 🔴 **DANGER** | any error (tool poisoning, leaked secrets, RCE) | 1 | **Do not install.** |
78
+ | ⚪ **UNKNOWN** | no MCP tools found statically (unsupported language, or tools built at runtime) | 0 | Can't check statically — capture `tools/list` (`--manifest`) or spawn the server (`--runtime --command`). |
79
+
80
+ The numeric score (0–100, shown as a secondary detail) still follows the error-cap rule below: any error caps it at 60. `--fail-under N` adds a CI gate that is independent of the verdict (it can turn a 🟡/🟢 into an exit-1 without changing the displayed verdict).
81
+
82
+ > **Languages:** mcpxray checks **Python and TypeScript** statically (FastMCP `@mcp.tool` and the TS SDK's `server.tool(...)` / `registerTool(...)` / low-level `ListToolsRequestSchema` shapes). Servers in other languages — or tools built dynamically at runtime — still return ⚪ UNKNOWN; capture `tools/list` (`mcpxray check --manifest dump.json`), or spawn the server and let mcpxray capture it live (`mcpxray check --runtime --command '<launch>'`).
83
+
84
+ ## Runtime capture (`--runtime --command`)
85
+
86
+ When source isn't parseable (compiled, 3rd-party, or tools built dynamically), `--runtime` **spawns the server, performs the MCP JSON-RPC handshake over stdio (`initialize` → `notifications/initialized` → `tools/list`), and feeds the captured tools through the same rules**. It's strictly opt-in and needs an explicit launch command:
87
+
88
+ ```bash
89
+ mcpxray check --runtime --command "python -m my_mcp_server"
90
+ mcpxray check --runtime --command "node dist/index.js" path/to/server # cwd = the path
91
+ ```
92
+
93
+ > ⚠️ **`--runtime` executes the server under inspection.** It is opt-in, runs the server with a bounded lifetime (timeouts + guaranteed teardown), and parses its response defensively — but provides **no OS-level sandbox** (no filesystem/network isolation). Only point it at servers you trust; for untrusted servers, run mcpxray inside a container or VM. Prefer `--manifest` when you already have a captured `tools/list`.
94
+
95
+ Runtime-captured tools carry no source text, so the source-scanning rules (MCP102 secrets / MCP103 RCE / MCP105 drift / MCP109 transport) can't fire — but schema and description rules (MCP104/106/107) still run on the captured definitions.
96
+
97
+ ## Auto-fix (`scan --fix` / `--diff`)
98
+
99
+ The one rule that's mechanically, unambiguously fixable is **MCP108** (unpinned dependencies): `scan --fix` pins a floating spec to its concrete floor version — `requests>=2.30.0` → `requests==2.30.0` (pip), `"zod": "^1.2.3"` → `"zod": "1.2.3"` (npm). It edits `pyproject.toml` / `package.json` in place (atomically); `--diff` prints the same changes as a unified diff and writes nothing.
100
+
101
+ ```bash
102
+ mcpxray scan path/to/server --diff # preview (exits 1 if changes are pending — CI-friendly)
103
+ mcpxray scan path/to/server --fix # apply in place
104
+ ```
105
+
106
+ What it does **not** touch:
107
+
108
+ - **No floor to pin** (`*`, `latest`, a bare `flask`, `>=2` with no patch) → skipped, left for you to resolve against a registry. mcpxray never invents a version.
109
+ - **Specs with extras/env markers** (`pkg[extra]>=1.2.3`, `pkg>=1.2.3 ; python_version>'3'`) → skipped (rewriting them textually is unsafe).
110
+ - **Every other rule** (MCP101–107, MCP109) → not auto-fixable; these need human judgment (a leaked secret isn't "fixed" by deleting it).
111
+
112
+ `--fix`/`--diff` are **static-source-only** — they rewrite files in place, so they reject `--manifest`, `--runtime`, and URL targets (point them at a local path). Every edit is a literal, uniquely-anchored replacement, so an ambiguous match is skipped rather than applied wrongly. Re-run `scan` after `--fix` to confirm the score improved.
113
+
114
+ ## Rules
115
+
116
+ Each finding is a `Diagnostic` with a severity (`error`/`warning`/`info`). **Any `error` caps the score at 60**, so a leaked secret or poisoned tool can never be diluted into a green grade.
117
+
118
+ | ID | OWASP MCP | Rule | Severity |
119
+ | --- | --- | --- | --- |
120
+ | MCP101 | Tool Poisoning | hidden instructions in a tool `description` ("ignore previous", exfiltrate-to-URL, hidden format/bidi chars) | error |
121
+ | MCP102 | Token/Secret Exposure | secrets/tokens/API keys in source (regex: private keys, `sk-`, AWS, GitHub, GitLab, Slack, Google, hardcoded creds) | error |
122
+ | MCP103 | Excess Permissions (MCP04) | dangerous capabilities: `os.system`, `eval`/`exec`, `pickle.loads`, `subprocess(..., shell=True)` | error |
123
+ | MCP104 | — (hygiene) | weak schema: no `required`, or a property with an empty `{}` schema | warning |
124
+ | MCP105 | — (correctness) | schema/implementation drift: a tool's declared `inputSchema` disagrees with its handler's parameters | warning |
125
+ | MCP106 | — (compat) | JSON-Schema incompatibilities that break Cursor/ChatGPT (`$ref`/`oneOf`/`anyOf`/`allOf`, missing `type`) | warning |
126
+ | MCP107 | — (context) | missing or oversized `description` (eats the model's context budget) | warning |
127
+ | MCP108 | Supply-Chain (MCP05) | unpinned dependencies with no lockfile (pip **and** npm — `^`/`~`/`>=`/`*` drift) | warning |
128
+ | MCP109 | — (transport) | HTTP/SSE transport exposed without TLS or authentication | warning |
129
+
130
+ ## Score & grades
131
+
132
+ | Grade | Score | Meaning |
133
+ | --- | --- | --- |
134
+ | **A** | 90–100 | Clean |
135
+ | **B** | 80–89 | Minor warnings |
136
+ | **C** | 70–79 | Some hygiene debt |
137
+ | **D** | 60–69 | Serious — errors present (capped) |
138
+ | **F** | 0–59 | Critical — errors present (capped) |
139
+
140
+ Deductions: `error` = −20, `warning` = −6, `info` = −1, clamped to `[0, 100]`.
141
+
142
+ ## CI
143
+
144
+ ```yaml
145
+ # .github/workflows/mcp.yml
146
+ name: mcpxray
147
+ on: [push, pull_request]
148
+ jobs:
149
+ lint:
150
+ runs-on: ubuntu-latest
151
+ steps:
152
+ - uses: actions/checkout@v4
153
+ - uses: astral-sh/setup-uv@v5
154
+ - run: uv tool install mcpxray-cli
155
+ # GitHub annotations + SARIF-friendly; fails on any ERROR
156
+ - run: mcpxray scan ./src --check -f github
157
+ # Optional: fail below a score bar
158
+ - run: mcpxray score ./src --fail-under 80
159
+ ```
160
+
161
+ ## Pre-commit hook
162
+
163
+ Lint on every commit from any MCP-server repo. Add `mcpxray` to your `.pre-commit-config.yaml`:
164
+
165
+ ```yaml
166
+ repos:
167
+ - repo: https://github.com/cloudroad-io/mcpxray
168
+ rev: v1.0.0 # pin to a release tag
169
+ hooks:
170
+ - id: mcpxray
171
+ args: ["./src"] # path to your server source
172
+ ```
173
+
174
+ The hook runs `mcpxray scan --check <path>` and fails the commit on any ERROR (tool poisoning, leaked secrets, RCE). Requires the `mcpxray-cli` distribution on PyPI; if you install it locally instead (`uv tool install mcpxray-cli`), set `language: system` on the hook.
175
+
176
+ ## How it works
177
+
178
+ 1. **Extract.** `PythonExtractor` walks `.py` files, finds `@mcp.tool` / `@server.tool` decorators, and lifts `name`, the docstring (→ `description`) and type hints (→ JSON Schema) straight from the AST — **no imports, no execution**. `ManifestExtractor` parses a captured `tools/list` JSON dump for servers in any language. For servers you can run, `--runtime --command` spawns it and captures `tools/list` live (`runtime.py`).
179
+ 2. **Lint.** Each rule sees the resulting `McpServer` IR and emits `Diagnostic`s.
180
+ 3. **Score.** Findings collapse to a 0–100 score with an error cap (mirrors OpenSSF Scorecard's shape).
181
+ 4. **Report.** `plain`, `json`, `github` annotations, or `sarif` (for GitHub code scanning).
182
+
183
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full pipeline and [CONTRIBUTING.md](CONTRIBUTING.md) to add a rule or extractor in one file.
184
+
185
+ ## Why mcpxray
186
+
187
+ - **Static, not runtime.** No need to start the server or trust what it reports at runtime — parse definitions from source. Deterministic and CI-safe.
188
+ - **MCP-semantic.** Knows about tool poisoning, schema compatibility, over-permissive tools — things generic SAST and OpenSSF Scorecard can't see.
189
+ - **Source-level locations.** Findings point at `file:line`, not anonymous runtime entries.
190
+ - **Plugin-friendly.** Add a rule or an extractor by subclassing + one decorator. Entry-points let external packages extend `mcpxray` without forking.
191
+
192
+ ## Dogfood
193
+
194
+ Scanned against the official [`modelcontextprotocol/python-sdk`](https://github.com/modelcontextprotocol/python-sdk) example servers:
195
+
196
+ | Server | Score | Findings |
197
+ | --- | --- | --- |
198
+ | `everything-server` | **82/100 (B)** | 3 warnings — an untyped `region` param, an all-optional schema, unpinned deps |
199
+ | `examples/mcpserver` | **58/100 (F)** | 7 warnings — missing descriptions, untyped Pydantic-model params |
200
+
201
+ (FastMCP's framework-injected `ctx: Context` parameter is correctly excluded from every tool's schema — the same way the SDK itself does it — so it doesn't generate noise.)
202
+
203
+ ## Roadmap
204
+
205
+ - **v0.2** — scope URL clones to the server entry point, TypeScript static extractor, rules MCP105/109, opt-in runtime `tools/list` capture (`--runtime --command`). ✅ shipped.
206
+ - **v1.0** — frozen plugin API (SemVer), `--fix`/`--diff`, pre-commit hook, GitHub Actions CI, PyPI trusted publishing. ✅ shipped (v1.0.0).
207
+ - **Later** (needs external services/accounts): hosted badge API + leaderboard (hosting), registry integrations (Glama/Smithery API keys). Full history: [`docs/v0.2-plan.md`](docs/v0.2-plan.md).
208
+
209
+ ## License
210
+
211
+ MIT.
@@ -0,0 +1,199 @@
1
+ # mcpxray
2
+
3
+ **Static linter + 0–100 scorecard for MCP servers.** `mcpxray` scans an MCP server's source (or a captured `tools/list` manifest) and flags tool poisoning, leaked secrets, dangerous capabilities, weak schemas and more — **locally, deterministically, in CI**. Point it at a GitHub URL or a local path and get a plain-language verdict — 🟢 ok / 🟡 caution / 🔴 danger — plus a 0–100 score and an SVG badge.
4
+
5
+ > MCP is the fastest-growing dev protocol since GraphQL (~97M SDK downloads/month), yet 7%+ of servers ship with vulnerabilities and the OWASP MCP Top 10 is a list, not a tool. `mcpxray` is the missing `npm audit` + OpenSSF Scorecard for MCP — static, local-first, OSS.
6
+
7
+ ## Status
8
+
9
+ ✅ **v1.0.0** — stable public API. Python + TypeScript static extractors, manifest extractor, opt-in runtime `tools/list` capture, rules **MCP101–109** (full OWASP MCP Top-10 mapping), `check`/`scan`/`score`/`badge`/`version`, plain/json/github/sarif/card reports, 0–100 score with error cap, SVG badge, `--fix`/`--diff` for MCP108, pre-commit hook, frozen plugin API (`__all__` + SemVer policy), tokenless PyPI trusted publishing. 224 tests.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv tool install mcpxray-cli
15
+ # or: pip install mcpxray-cli
16
+ ```
17
+
18
+ The PyPI distribution is `mcpxray-cli` (the name `mcpxray` is blocked on PyPI by an unrelated project); the command it installs is still `mcpxray`.
19
+
20
+ Requires Python ≥ 3.10.
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ # Is this MCP server safe to install? Point mcpxray at a GitHub URL or a local path.
26
+ mcpxray check https://github.com/owner/repo
27
+ mcpxray check path/to/my-mcp-server
28
+
29
+ # Verdict + the full finding list
30
+ mcpxray check path/to/my-mcp-server --details
31
+
32
+ # Not a Python server? Hand mcpxray a captured tools/list dump (any language)
33
+ mcpxray check --manifest tools-list.json
34
+
35
+ # ...or spawn the server and let mcpxray capture tools/list live (any language)
36
+ mcpxray check --runtime --command "python -m my_mcp_server"
37
+
38
+ # --- power users / CI -----------------------------------------------------
39
+ # Lint and print findings (CI gate: --check exits 1 on any ERROR)
40
+ mcpxray scan path/to/my-mcp-server --check -f github
41
+ # Just the 0–100 score, fail below a bar
42
+ mcpxray score path/to/my-mcp-server --fail-under 80
43
+ # Embed an SVG score badge in your README
44
+ mcpxray badge path/to/my-mcp-server -o docs/score.svg
45
+ ```
46
+
47
+ ## Commands
48
+
49
+ | Command | Purpose |
50
+ | --- | --- |
51
+ | `mcpxray check <URL \| PATH> [--manifest FILE] [--runtime --command CMD] [--details\|-v] [--fail-under N]` | **Friendly safety verdict** (🟢/🟡/🔴/⚪) + recommendation. Clones a GitHub URL automatically; exits 1 on 🔴 danger or below `--fail-under`. |
52
+ | `mcpxray scan [PATH] [--manifest FILE] [--runtime --command CMD] [-f plain\|json\|github\|sarif\|card] [--check] [--fix] [--diff]` | Lint a server and print findings. `--check` exits 1 on any ERROR (CI gate); `--fix` pins unpinned deps in place, `--diff` previews (local source only). |
53
+ | `mcpxray score [PATH] [--manifest FILE] [--runtime --command CMD] [--fail-under N]` | Print the 0–100 score and grade; exit 1 below `--fail-under`. |
54
+ | `mcpxray badge [PATH \| --score N] [-o FILE]` | Render an SVG score badge (`-o -` for stdout). |
55
+ | `mcpxray version` | Print the version. |
56
+
57
+ ## What the verdict means
58
+
59
+ `mcpxray check` turns findings into a traffic-light verdict instead of a raw score:
60
+
61
+ | Verdict | When | Exit | What to do |
62
+ | --- | --- | --- | --- |
63
+ | 🟢 **OK** | nothing found | 0 | Safe to add to Claude Code. |
64
+ | 🟡 **CAUTION** | no errors, but warnings (weak schemas, unpinned deps, …) | 0 | Usable — mind the listed weaknesses. |
65
+ | 🔴 **DANGER** | any error (tool poisoning, leaked secrets, RCE) | 1 | **Do not install.** |
66
+ | ⚪ **UNKNOWN** | no MCP tools found statically (unsupported language, or tools built at runtime) | 0 | Can't check statically — capture `tools/list` (`--manifest`) or spawn the server (`--runtime --command`). |
67
+
68
+ The numeric score (0–100, shown as a secondary detail) still follows the error-cap rule below: any error caps it at 60. `--fail-under N` adds a CI gate that is independent of the verdict (it can turn a 🟡/🟢 into an exit-1 without changing the displayed verdict).
69
+
70
+ > **Languages:** mcpxray checks **Python and TypeScript** statically (FastMCP `@mcp.tool` and the TS SDK's `server.tool(...)` / `registerTool(...)` / low-level `ListToolsRequestSchema` shapes). Servers in other languages — or tools built dynamically at runtime — still return ⚪ UNKNOWN; capture `tools/list` (`mcpxray check --manifest dump.json`), or spawn the server and let mcpxray capture it live (`mcpxray check --runtime --command '<launch>'`).
71
+
72
+ ## Runtime capture (`--runtime --command`)
73
+
74
+ When source isn't parseable (compiled, 3rd-party, or tools built dynamically), `--runtime` **spawns the server, performs the MCP JSON-RPC handshake over stdio (`initialize` → `notifications/initialized` → `tools/list`), and feeds the captured tools through the same rules**. It's strictly opt-in and needs an explicit launch command:
75
+
76
+ ```bash
77
+ mcpxray check --runtime --command "python -m my_mcp_server"
78
+ mcpxray check --runtime --command "node dist/index.js" path/to/server # cwd = the path
79
+ ```
80
+
81
+ > ⚠️ **`--runtime` executes the server under inspection.** It is opt-in, runs the server with a bounded lifetime (timeouts + guaranteed teardown), and parses its response defensively — but provides **no OS-level sandbox** (no filesystem/network isolation). Only point it at servers you trust; for untrusted servers, run mcpxray inside a container or VM. Prefer `--manifest` when you already have a captured `tools/list`.
82
+
83
+ Runtime-captured tools carry no source text, so the source-scanning rules (MCP102 secrets / MCP103 RCE / MCP105 drift / MCP109 transport) can't fire — but schema and description rules (MCP104/106/107) still run on the captured definitions.
84
+
85
+ ## Auto-fix (`scan --fix` / `--diff`)
86
+
87
+ The one rule that's mechanically, unambiguously fixable is **MCP108** (unpinned dependencies): `scan --fix` pins a floating spec to its concrete floor version — `requests>=2.30.0` → `requests==2.30.0` (pip), `"zod": "^1.2.3"` → `"zod": "1.2.3"` (npm). It edits `pyproject.toml` / `package.json` in place (atomically); `--diff` prints the same changes as a unified diff and writes nothing.
88
+
89
+ ```bash
90
+ mcpxray scan path/to/server --diff # preview (exits 1 if changes are pending — CI-friendly)
91
+ mcpxray scan path/to/server --fix # apply in place
92
+ ```
93
+
94
+ What it does **not** touch:
95
+
96
+ - **No floor to pin** (`*`, `latest`, a bare `flask`, `>=2` with no patch) → skipped, left for you to resolve against a registry. mcpxray never invents a version.
97
+ - **Specs with extras/env markers** (`pkg[extra]>=1.2.3`, `pkg>=1.2.3 ; python_version>'3'`) → skipped (rewriting them textually is unsafe).
98
+ - **Every other rule** (MCP101–107, MCP109) → not auto-fixable; these need human judgment (a leaked secret isn't "fixed" by deleting it).
99
+
100
+ `--fix`/`--diff` are **static-source-only** — they rewrite files in place, so they reject `--manifest`, `--runtime`, and URL targets (point them at a local path). Every edit is a literal, uniquely-anchored replacement, so an ambiguous match is skipped rather than applied wrongly. Re-run `scan` after `--fix` to confirm the score improved.
101
+
102
+ ## Rules
103
+
104
+ Each finding is a `Diagnostic` with a severity (`error`/`warning`/`info`). **Any `error` caps the score at 60**, so a leaked secret or poisoned tool can never be diluted into a green grade.
105
+
106
+ | ID | OWASP MCP | Rule | Severity |
107
+ | --- | --- | --- | --- |
108
+ | MCP101 | Tool Poisoning | hidden instructions in a tool `description` ("ignore previous", exfiltrate-to-URL, hidden format/bidi chars) | error |
109
+ | MCP102 | Token/Secret Exposure | secrets/tokens/API keys in source (regex: private keys, `sk-`, AWS, GitHub, GitLab, Slack, Google, hardcoded creds) | error |
110
+ | MCP103 | Excess Permissions (MCP04) | dangerous capabilities: `os.system`, `eval`/`exec`, `pickle.loads`, `subprocess(..., shell=True)` | error |
111
+ | MCP104 | — (hygiene) | weak schema: no `required`, or a property with an empty `{}` schema | warning |
112
+ | MCP105 | — (correctness) | schema/implementation drift: a tool's declared `inputSchema` disagrees with its handler's parameters | warning |
113
+ | MCP106 | — (compat) | JSON-Schema incompatibilities that break Cursor/ChatGPT (`$ref`/`oneOf`/`anyOf`/`allOf`, missing `type`) | warning |
114
+ | MCP107 | — (context) | missing or oversized `description` (eats the model's context budget) | warning |
115
+ | MCP108 | Supply-Chain (MCP05) | unpinned dependencies with no lockfile (pip **and** npm — `^`/`~`/`>=`/`*` drift) | warning |
116
+ | MCP109 | — (transport) | HTTP/SSE transport exposed without TLS or authentication | warning |
117
+
118
+ ## Score & grades
119
+
120
+ | Grade | Score | Meaning |
121
+ | --- | --- | --- |
122
+ | **A** | 90–100 | Clean |
123
+ | **B** | 80–89 | Minor warnings |
124
+ | **C** | 70–79 | Some hygiene debt |
125
+ | **D** | 60–69 | Serious — errors present (capped) |
126
+ | **F** | 0–59 | Critical — errors present (capped) |
127
+
128
+ Deductions: `error` = −20, `warning` = −6, `info` = −1, clamped to `[0, 100]`.
129
+
130
+ ## CI
131
+
132
+ ```yaml
133
+ # .github/workflows/mcp.yml
134
+ name: mcpxray
135
+ on: [push, pull_request]
136
+ jobs:
137
+ lint:
138
+ runs-on: ubuntu-latest
139
+ steps:
140
+ - uses: actions/checkout@v4
141
+ - uses: astral-sh/setup-uv@v5
142
+ - run: uv tool install mcpxray-cli
143
+ # GitHub annotations + SARIF-friendly; fails on any ERROR
144
+ - run: mcpxray scan ./src --check -f github
145
+ # Optional: fail below a score bar
146
+ - run: mcpxray score ./src --fail-under 80
147
+ ```
148
+
149
+ ## Pre-commit hook
150
+
151
+ Lint on every commit from any MCP-server repo. Add `mcpxray` to your `.pre-commit-config.yaml`:
152
+
153
+ ```yaml
154
+ repos:
155
+ - repo: https://github.com/cloudroad-io/mcpxray
156
+ rev: v1.0.0 # pin to a release tag
157
+ hooks:
158
+ - id: mcpxray
159
+ args: ["./src"] # path to your server source
160
+ ```
161
+
162
+ The hook runs `mcpxray scan --check <path>` and fails the commit on any ERROR (tool poisoning, leaked secrets, RCE). Requires the `mcpxray-cli` distribution on PyPI; if you install it locally instead (`uv tool install mcpxray-cli`), set `language: system` on the hook.
163
+
164
+ ## How it works
165
+
166
+ 1. **Extract.** `PythonExtractor` walks `.py` files, finds `@mcp.tool` / `@server.tool` decorators, and lifts `name`, the docstring (→ `description`) and type hints (→ JSON Schema) straight from the AST — **no imports, no execution**. `ManifestExtractor` parses a captured `tools/list` JSON dump for servers in any language. For servers you can run, `--runtime --command` spawns it and captures `tools/list` live (`runtime.py`).
167
+ 2. **Lint.** Each rule sees the resulting `McpServer` IR and emits `Diagnostic`s.
168
+ 3. **Score.** Findings collapse to a 0–100 score with an error cap (mirrors OpenSSF Scorecard's shape).
169
+ 4. **Report.** `plain`, `json`, `github` annotations, or `sarif` (for GitHub code scanning).
170
+
171
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full pipeline and [CONTRIBUTING.md](CONTRIBUTING.md) to add a rule or extractor in one file.
172
+
173
+ ## Why mcpxray
174
+
175
+ - **Static, not runtime.** No need to start the server or trust what it reports at runtime — parse definitions from source. Deterministic and CI-safe.
176
+ - **MCP-semantic.** Knows about tool poisoning, schema compatibility, over-permissive tools — things generic SAST and OpenSSF Scorecard can't see.
177
+ - **Source-level locations.** Findings point at `file:line`, not anonymous runtime entries.
178
+ - **Plugin-friendly.** Add a rule or an extractor by subclassing + one decorator. Entry-points let external packages extend `mcpxray` without forking.
179
+
180
+ ## Dogfood
181
+
182
+ Scanned against the official [`modelcontextprotocol/python-sdk`](https://github.com/modelcontextprotocol/python-sdk) example servers:
183
+
184
+ | Server | Score | Findings |
185
+ | --- | --- | --- |
186
+ | `everything-server` | **82/100 (B)** | 3 warnings — an untyped `region` param, an all-optional schema, unpinned deps |
187
+ | `examples/mcpserver` | **58/100 (F)** | 7 warnings — missing descriptions, untyped Pydantic-model params |
188
+
189
+ (FastMCP's framework-injected `ctx: Context` parameter is correctly excluded from every tool's schema — the same way the SDK itself does it — so it doesn't generate noise.)
190
+
191
+ ## Roadmap
192
+
193
+ - **v0.2** — scope URL clones to the server entry point, TypeScript static extractor, rules MCP105/109, opt-in runtime `tools/list` capture (`--runtime --command`). ✅ shipped.
194
+ - **v1.0** — frozen plugin API (SemVer), `--fix`/`--diff`, pre-commit hook, GitHub Actions CI, PyPI trusted publishing. ✅ shipped (v1.0.0).
195
+ - **Later** (needs external services/accounts): hosted badge API + leaderboard (hosting), registry integrations (Glama/Smithery API keys). Full history: [`docs/v0.2-plan.md`](docs/v0.2-plan.md).
196
+
197
+ ## License
198
+
199
+ MIT.
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "mcpxray-cli"
3
+ version = "1.0.0"
4
+ description = "Static linter + 0-100 scorecard for MCP servers. Catches tool poisoning, secret leaks, schema drift and more — locally, deterministically, in CI."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "typer>=0.12",
9
+ "pyyaml>=6",
10
+ "tomli>=2; python_version < '3.11'",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "Mitia"
15
+ email = "mitia@cloudroad.io"
16
+
17
+ [project.scripts]
18
+ mcpxray = "mcpxray.cli:app"
19
+
20
+ [project.entry-points."mcpxray.extractors"]
21
+
22
+ [project.entry-points."mcpxray.rules"]
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.11.28,<0.12.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.build-backend]
29
+ module-name = "mcpxray"
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = [
37
+ "E",
38
+ "F",
39
+ "I",
40
+ "UP",
41
+ "B",
42
+ "SIM",
43
+ ]
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ "src/mcpxray/cli.py" = ["B008"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ addopts = "--strict-markers"
51
+ pythonpath = ["."]
52
+
53
+ [dependency-groups]
54
+ dev = [
55
+ "pytest>=8",
56
+ "pytest-cov>=5",
57
+ "ruff>=0.6",
58
+ "pre-commit>=3.7",
59
+ ]
@@ -0,0 +1,59 @@
1
+ [project]
2
+ # Distribution name on PyPI. The CLI command and the import package stay
3
+ # `mcpxray`; the dist needs the `-cli` suffix because PyPI blocks `mcpxray`
4
+ # as confusable with the existing `mcp-xray` project (PyPI's name check
5
+ # ignores -/_/. and treats i/l and o as interchangeable).
6
+ name = "mcpxray-cli"
7
+ version = "1.0.0"
8
+ description = "Static linter + 0-100 scorecard for MCP servers. Catches tool poisoning, secret leaks, schema drift and more — locally, deterministically, in CI."
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Mitia", email = "mitia@cloudroad.io" }
12
+ ]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "typer>=0.12",
16
+ "pyyaml>=6",
17
+ "tomli>=2; python_version < '3.11'",
18
+ ]
19
+
20
+ [project.scripts]
21
+ mcpxray = "mcpxray.cli:app"
22
+
23
+ # External plugins self-register via these entry-point groups (built-ins load
24
+ # directly through decorator-based registries — see src/mcpxray/{extract,rules}).
25
+ [project.entry-points."mcpxray.extractors"]
26
+ [project.entry-points."mcpxray.rules"]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.11.28,<0.12.0"]
30
+ build-backend = "uv_build"
31
+
32
+ # uv_build derives the module from the dist name (`mcpxray_cli`); the import
33
+ # package is `mcpxray`, so point at it explicitly.
34
+ [tool.uv.build-backend]
35
+ module-name = "mcpxray"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=8",
40
+ "pytest-cov>=5",
41
+ "ruff>=0.6",
42
+ "pre-commit>=3.7",
43
+ ]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py310"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B", "SIM"]
51
+
52
+ # typer/click use call expressions as default arguments by design (B008).
53
+ [tool.ruff.lint.per-file-ignores]
54
+ "src/mcpxray/cli.py" = ["B008"]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ addopts = "--strict-markers"
59
+ pythonpath = ["."]
@@ -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__"]
@@ -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()
@@ -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
+ )