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.
- mcpxray_cli-1.0.0/PKG-INFO +211 -0
- mcpxray_cli-1.0.0/README.md +199 -0
- mcpxray_cli-1.0.0/pyproject.toml +59 -0
- mcpxray_cli-1.0.0/pyproject.toml.orig +59 -0
- mcpxray_cli-1.0.0/src/mcpxray/__init__.py +18 -0
- mcpxray_cli-1.0.0/src/mcpxray/__main__.py +6 -0
- mcpxray_cli-1.0.0/src/mcpxray/badge.py +49 -0
- mcpxray_cli-1.0.0/src/mcpxray/cli.py +322 -0
- mcpxray_cli-1.0.0/src/mcpxray/extract/__init__.py +16 -0
- mcpxray_cli-1.0.0/src/mcpxray/extract/base.py +61 -0
- mcpxray_cli-1.0.0/src/mcpxray/extract/manifest.py +97 -0
- mcpxray_cli-1.0.0/src/mcpxray/extract/python_static.py +316 -0
- mcpxray_cli-1.0.0/src/mcpxray/extract/typescript_static.py +552 -0
- mcpxray_cli-1.0.0/src/mcpxray/fix.py +166 -0
- mcpxray_cli-1.0.0/src/mcpxray/ir.py +186 -0
- mcpxray_cli-1.0.0/src/mcpxray/report/__init__.py +47 -0
- mcpxray_cli-1.0.0/src/mcpxray/report/card.py +137 -0
- mcpxray_cli-1.0.0/src/mcpxray/report/github.py +35 -0
- mcpxray_cli-1.0.0/src/mcpxray/report/json.py +50 -0
- mcpxray_cli-1.0.0/src/mcpxray/report/plain.py +35 -0
- mcpxray_cli-1.0.0/src/mcpxray/report/sarif.py +76 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/__init__.py +8 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/base.py +67 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/__init__.py +5 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/descriptions.py +103 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/schema.py +115 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/source.py +95 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/supply.py +92 -0
- mcpxray_cli-1.0.0/src/mcpxray/rules/builtin/transport.py +79 -0
- mcpxray_cli-1.0.0/src/mcpxray/runtime.py +260 -0
- mcpxray_cli-1.0.0/src/mcpxray/score.py +71 -0
- mcpxray_cli-1.0.0/src/mcpxray/source.py +253 -0
- 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,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
|
+
)
|