This document is the legacy scoring reference (score.overall, 0–100). For the full picture (legacy + v2), read the Scoring developer guide first.
CI gate on legacy score:
--min-score 70 --fail-on-critical· CI Integration
v2 scoring: Scoring spec v2
The legacy overall score is 0–100 where higher is better. It uses severity weights and exponential decay — see formulas below.
- Risk index (0–100): Higher is worse. Linear measure of total risk burden.
- Default scans also compute v2 (
score_v2) — this doc does not cover v2. See scoring guide.
Every report includes score.basis showing which severities contributed. The scanner verifies the math on every run.
Example: examples/vulnerable-mcp-server/server.py scores approximately 1/100 legacy overall (v2 is separate — see scoring guide).
Compliance findings (OWASP mapping) appear in reports but do not affect the score.
Implementation: src/mcts/scoring/engine.py
Reports may include score_breakdown with decomposed scores:
| Field | Meaning |
|---|---|
mcp_surface |
Tool/metadata analyzers (permissions, injection, chains, …) |
supply_chain |
CVE and dependency analyzers (supply_chain, vulnerable_package, npm_audit) |
dependency_hygiene |
Unpinned version findings |
composite |
Weighted blend: 0.6×mcp + 0.25×supply + 0.15×hygiene |
score.overall remains the full finding set (backward compatible). --min-score gates on score.overall.
scan_scope on each report: entrypoint, repository, config-static, live, or snapshot.
Implementation: src/mcts/scoring/partitions.py, src/mcts/scoring/categories.py
- Deterministic — same findings always produce the same score
- Auditable —
score.basisdocuments exact severity counts used - CI-friendly — legacy gates on overall score, critical count, and category thresholds; v2 gates documented in scoring-spec-v2
- Separated compliance — OWASP meta-findings do not inflate risk score
The scanner calls RiskScoringEngine.verify() after scoring; mismatch raises RuntimeError (regression guard).
| Severity | Raw risk points | Rationale |
|---|---|---|
| Critical | 25 | Immediate exploitation or catastrophic impact |
| High | 10 | Serious misuse or strong attack enabler |
| Medium | 3 | Meaningful weakness requiring attention |
| Low | 1 | Informational or defense-in-depth gap |
raw_risk = critical×25 + high×10 + medium×3 + low×1Constants: RISK_WEIGHTS in scoring/engine.py.
Exponential decay — higher is better:
overall = round(100 × e^(-raw_risk / 50))
| Property | Value |
|---|---|
raw_risk == 0 |
Score 100 (perfect) |
| Clamp range | [0, 100] |
| Decay scale | RISK_DECAY_SCALE = 50 |
| Findings | Raw risk | Overall score |
|---|---|---|
| None | 0 | 100 |
| 1 High | 10 | 82 |
| 3 Medium | 9 | 83 |
| 1 Critical + 2 High | 45 | 41 |
| 3 Critical + 7 High + 2 Medium | 151 | 5 |
Example servers: examples/baseline-mcp-server/ (~100), examples/medium-risk-mcp-server/ (~67), examples/vulnerable-mcp-server/ (~5).
Linear cap on raw risk — higher is worse:
risk_index = min(100, raw_risk)
Use when dashboards should show "risk burden" without exponential inversion. A server with raw_risk 151 shows risk_index 100 (capped).
Findings where analyzer == "compliance" are excluded from score calculation:
NON_SCORING_ANALYZERS = frozenset({"compliance"})Compliance findings still appear in reports, HTML OWASP section, and SARIF — they inform governance but should not double-penalize already-scored issues.
score.basis.excluded_non_scorable counts excluded rows.
{
"score": {
"overall": 5,
"risk_index": 100,
"raw_risk": 151,
"penalty": 151,
"basis": {
"critical": 3,
"high": 7,
"medium": 2,
"low": 0,
"scorable_total": 12,
"excluded_non_scorable": 2
}
},
"summary": {
"critical": 3,
"high": 7,
"medium": 2,
"low": 0,
"total": 12
}
}Note: summary reflects scorable findings used for score; total finding count in findings[] may be higher when compliance rows exist.
penalty is a deprecated alias for raw_risk (backward compatibility).
Category scores power the terminal dashboard, HTML radar chart, and --fail-on-category gates.
Defined in report/data.py → CATEGORY_DEFS:
| Key | Label | Max points | Analyzers mapped |
|---|---|---|---|
permissions |
Excessive Permissions | 20 | permission_analyzer |
injection |
Injection & Metadata | 20 | prompt_injection, metadata_integrity, schema_surface |
execution |
Execution & Path Risk | 15 | command_execution, path_validation, tool_abuse |
data_leakage |
Data Leakage Risk | 15 | data_leakage |
attack_chains |
Attack Chain Risk | 15 | attack_chains |
shadowing |
Cross-Server Shadowing | 5 | cross_server |
jailbreak |
Jailbreak Resistance | 10 | jailbreak |
For each category:
- Collect findings whose
analyzermatches the category's analyzer list - Sum severity weights (same 25/10/3/1 table)
- Category score =
min(maximum, weighted_sum)
HTML radar chart compares your category scores to INDUSTRY_BENCHMARK defaults:
| Category | Benchmark |
|---|---|
| permissions | 8 |
| injection | 6 |
| execution | 5 |
| data_leakage | 5 |
| attack_chains | 4 |
| shadowing | 2 |
| jailbreak | 3 |
Benchmarks are illustrative overlays — not pass/fail thresholds.
Exit code 1 when a gate fails; 2 for usage/consent errors.
| Flag | Fails when |
|---|---|
--fail-on-critical |
summary.critical > 0 (scorable findings) |
--min-score N |
score.overall < N |
--max-critical N |
summary.critical > N |
--fail-on-category KEY:LIMIT |
Legacy category score ≥ LIMIT |
Category gates are inclusive at the limit: --fail-on-category permissions:10 fails when permissions category score is 10 or higher.
Requires --scoring v2 or both (default). Canonical reference: Scoring spec v2 · Migration guide.
| Flag | Fails when |
|---|---|
--min-security-score N |
score_v2.security_score < N (needs corpus stats) |
--max-absolute-risk N |
score_v2.absolute_risk > N |
--max-risk-level LEVEL |
score_v2.risk_level exceeds band |
--min-category-score-v2 KEY:MIN |
v2 OWASP tile < MIN (100=good) |
REST API returns gate_violations but does not change HTTP status — use CLI for CI exit codes.
mcts scan ./repo/ \
--min-score 75 \
--max-critical 0 \
--fail-on-category permissions:10 \
--fail-on-category injection:12 \
--fail-on-category execution:10Tune limits per team risk appetite. Start strict on max-critical and relax min-score as debt is burned down.
| Score range | Grade | Posture label |
|---|---|---|
| 90–100 | A | Low risk |
| 80–89 | B | Moderate |
| 70–79 | C | Elevated |
| 60–69 | D | High |
| 0–59 | F | Critical |
Grades are derived from score.overall in report/data.py.
| Mode | Status | Notes |
|---|---|---|
Legacy exponential (--scoring legacy) |
Shipped | This document — score.overall |
Multi-factor v2 (--scoring v2|both) |
Shipped (default both) |
Scoring spec v2 |
AIVSS v2 (--scoring aivss) |
Missing | GAP-060 |
| CVSS v4 vector per finding | Missing | GAP-061 |
| Runtime trust score (live/proxy) | Planned | L10-01 |
See Feature Expansion Plan — Analyzer.
RiskScoringEngine.verify(findings, score) # must return TrueCalled at end of Scanner.run(). Prevents:
- Stale cached scores in tests
- Manual score tampering in JSON
- Regression when weights change without test updates