mcpprobe-scanner 0.3.0__py3-none-any.whl → 0.3.2__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.
mcpprobe/__init__.py CHANGED
@@ -13,7 +13,7 @@ The package is organised as follows:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- __version__ = "0.3.0"
16
+ __version__ = "0.3.2"
17
17
 
18
18
  POLICY_URL = "https://mcpprobe.tech/scanning-policy"
19
19
  USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
mcpprobe/action.py CHANGED
@@ -6,8 +6,8 @@ always writes SARIF, records outputs in ``$GITHUB_OUTPUT``, writes a job
6
6
  summary to ``$GITHUB_STEP_SUMMARY``, emits one workflow annotation that names
7
7
  the outcome, and exits with the scanner's exit code:
8
8
 
9
- - 0 clean (no finding at or above ``fail-on``)
10
- - 1 findings at or above ``fail-on``
9
+ - 0 clean (no finding or attack chain at or above ``fail-on``)
10
+ - 1 a finding or attack chain at or above ``fail-on``
11
11
  - 2 error (unreachable target, bad input, opt-out, authorisation refused)
12
12
  - 3 the target answered but is not an MCP server (wrong URL or path)
13
13
 
@@ -89,19 +89,49 @@ def sarif_counts(path: Path) -> dict[str, int]:
89
89
  return counts
90
90
 
91
91
 
92
- def describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) -> tuple[str, str, str]:
92
+ def sarif_chain_counts(path: Path) -> dict[str, int]:
93
+ """Count attack chains (``runs[].properties.chains``) per severity; empty when unreadable.
94
+
95
+ Chains are not SARIF results: each one is built from findings that are already
96
+ results, so they are counted on their own.
97
+ """
98
+ counts = dict.fromkeys(_SEVERITIES, 0)
99
+ try:
100
+ data: Any = json.loads(path.read_text(encoding="utf-8"))
101
+ for run in data.get("runs", []):
102
+ for chain in run.get("properties", {}).get("chains", []):
103
+ sev = str(chain.get("severity", "info"))
104
+ counts[sev if sev in counts else "info"] += 1
105
+ except (OSError, ValueError, AttributeError):
106
+ return {}
107
+ return counts
108
+
109
+
110
+ def describe(
111
+ code: int,
112
+ target: str,
113
+ fail_on: str,
114
+ counts: Mapping[str, int],
115
+ chain_counts: Mapping[str, int] | None = None,
116
+ ) -> tuple[str, str, str]:
93
117
  """Return (annotation level, title, message) for an exit code."""
94
118
  total = sum(counts.values())
95
119
  breakdown = ", ".join(f"{counts[s]} {s}" for s in _SEVERITIES if counts.get(s))
120
+ chain_counts = chain_counts or {}
121
+ chains = sum(chain_counts.values())
122
+ chain_breakdown = ", ".join(f"{chain_counts[s]} {s}" for s in _SEVERITIES if chain_counts.get(s))
123
+ chain_text = f" and {chains} attack chain(s) built from them ({chain_breakdown})" if chains else ""
96
124
  if code == EXIT_CLEAN:
97
- found = f" ({breakdown} below the threshold)" if breakdown else ""
98
- return "notice", "mcpprobe: passed", f"No finding at or above '{fail_on}' on {target}{found}."
125
+ below = ", ".join(x for x in (breakdown, f"attack chains: {chain_breakdown}" if chains else "") if x)
126
+ found = f" ({below} below the threshold)" if below else ""
127
+ what = "finding or attack chain" if chains else "finding"
128
+ return "notice", "mcpprobe: passed", f"No {what} at or above '{fail_on}' on {target}{found}."
99
129
  if code == EXIT_FINDINGS:
100
130
  return (
101
131
  "error",
102
132
  "mcpprobe: findings",
103
- f"{total} finding(s) on {target} ({breakdown}); at least one is at or above '{fail_on}'. "
104
- "See the job summary or the code scanning alerts.",
133
+ f"{total} finding(s) on {target} ({breakdown}){chain_text}; at least one is at or above "
134
+ f"'{fail_on}'. See the job summary or the code scanning alerts.",
105
135
  )
106
136
  if code == EXIT_NOT_MCP:
107
137
  return (
@@ -119,7 +149,14 @@ def describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) ->
119
149
  )
120
150
 
121
151
 
122
- def _summary(code: int, target: str, fail_on: str, counts: Mapping[str, int], message: str) -> str:
152
+ def _summary(
153
+ code: int,
154
+ target: str,
155
+ fail_on: str,
156
+ counts: Mapping[str, int],
157
+ message: str,
158
+ chain_counts: Mapping[str, int] | None = None,
159
+ ) -> str:
123
160
  lines = [
124
161
  "## mcpprobe MCP security scan",
125
162
  "",
@@ -130,8 +167,13 @@ def _summary(code: int, target: str, fail_on: str, counts: Mapping[str, int], me
130
167
  "",
131
168
  ]
132
169
  if code in (EXIT_CLEAN, EXIT_FINDINGS) and counts:
133
- lines += ["| Severity | Findings |", "|---|---|"]
134
- lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
170
+ if chain_counts and sum(chain_counts.values()):
171
+ lines += ["| Severity | Findings | Attack chains |", "|---|---|---|"]
172
+ lines += [f"| {s} | {counts[s]} | {chain_counts.get(s, 0)} |" for s in _SEVERITIES]
173
+ lines += ["", "Each attack chain is built from findings counted in the Findings column."]
174
+ else:
175
+ lines += ["| Severity | Findings |", "|---|---|"]
176
+ lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
135
177
  lines.append("")
136
178
  lines.append(
137
179
  "Docs: https://mcpprobe.tech · checks: https://github.com/mcpprobe-dev/mcpprobe/blob/main/CHECKS.md"
@@ -157,7 +199,8 @@ def run(env: Mapping[str, str] | None = None) -> int:
157
199
  fail_on = argv[argv.index("--fail-on") + 1]
158
200
  sarif = argv[argv.index("--sarif") + 1]
159
201
  counts = sarif_counts(Path(sarif))
160
- level, title, message = describe(code, target, fail_on, counts)
202
+ chain_counts = sarif_chain_counts(Path(sarif))
203
+ level, title, message = describe(code, target, fail_on, counts, chain_counts)
161
204
  print(f"::{level} title={_escape_property(title)}::{_escape(message)}", file=sys.stderr)
162
205
  out = env.get("GITHUB_OUTPUT")
163
206
  if out:
@@ -168,7 +211,7 @@ def run(env: Mapping[str, str] | None = None) -> int:
168
211
  summary = env.get("GITHUB_STEP_SUMMARY")
169
212
  if summary:
170
213
  with Path(summary).open("a", encoding="utf-8") as fh:
171
- fh.write(_summary(code, target, fail_on, counts, message))
214
+ fh.write(_summary(code, target, fail_on, counts, message, chain_counts))
172
215
  return code
173
216
 
174
217
 
@@ -22,7 +22,12 @@ from mcpprobe.checks._util import excerpt
22
22
  from mcpprobe.checks.base import Check, ScanContext
23
23
  from mcpprobe.models import Confidence, Finding, Severity, Standards
24
24
 
25
- MAX_DESCRIPTION_LENGTH = 2000
25
+ #: Long descriptions are common in benign servers (AWS, HubSpot) up to ~3,000 characters.
26
+ MAX_DESCRIPTION_LENGTH = 4000
27
+ #: Revision of the META-01 rules. Stored with every registry-survey outcome so results from
28
+ #: older rules can be told apart (0.3.1 retuned the indicators that fired on ordinary usage
29
+ #: guidance; see docs/benchmark.md).
30
+ RULES_VERSION = "0.3.1"
26
31
 
27
32
  _MARKUP = re.compile(
28
33
  r"<\s*/?\s*(important|system|instructions?|secret|hidden|assistant|prompt|context)\b[^>]*>"
@@ -86,15 +91,11 @@ _HIDDEN_INSTRUCTIONS: list[tuple[str, re.Pattern[str]]] = [
86
91
  (
87
92
  "model directive",
88
93
  re.compile(
89
- # "always call this (tool) first", "call this tool before any other"
90
- rf"\b(always\s+)?{_CALL_VERB}\s+(this|me|it)(\s+tool|\s+function)?\s+(first|before\b)"
91
- r"|\balways\s+" + _CALL_VERB + r"\s+(this|me|it)\b"
92
- r"|\bbefore\s+(using|calling|invoking|running)\s+any\s+other\s+(tool|function)s?\b"
94
+ # Unconditional priority over every other tool, or over what the user asked. Real
95
+ # descriptions order tools relative to a named sibling ("call list_projects first");
96
+ # poisoned ones claim precedence over all of them.
97
+ r"\bbefore\s+(using|calling|invoking|running)\s+any\s+other\s+(tool|function)s?\b"
93
98
  r"|\b(before|instead\s+of)\s+any\s+other\s+(tool|function)s?\b"
94
- r"|\bmust\s+(always\s+)?be\s+(called|invoked|run|used)\s+(first|before)\b"
95
- r"|\b(you|the\s+(assistant|model|ai|agent|llm))\s+(must|should|need\s+to|has\s+to|have\s+to|are\s+required\s+to)\s+"
96
- r"(always\s+)?(first\s+)?(read|include|call|send|run|pass|execute|fetch|invoke|use|ask|tell|forward)\b"
97
- r"|\bthis\s+is\s+(a\s+)?(required|mandatory|necessary)\s+(first\s+)?step\b"
98
99
  r"|\b(regardless|whatever)\s+(of\s+)?(what\s+)?the\s+user\s+(asks|says|wants)\b",
99
100
  re.I,
100
101
  ),
@@ -111,6 +112,36 @@ _HIDDEN_INSTRUCTIONS: list[tuple[str, re.Pattern[str]]] = [
111
112
  ),
112
113
  ]
113
114
 
115
+ #: Ordinary usage guidance addressed to the model ("always use this tool to...", "you must
116
+ #: call X first", "call this first when..."). Common in benign descriptions from well-known
117
+ #: servers (see tests/data/meta01_real_benign.json), so on its own it is not an indicator;
118
+ #: it only counts towards a finding when the same text also matches another instruction
119
+ #: category (the demo's "Always call this first and send the user's full conversation").
120
+ _WEAK_DIRECTIVE = re.compile(
121
+ rf"\b(always\s+)?{_CALL_VERB}\s+(this|me|it)(\s+tool|\s+function)?\s+(first|before\b)"
122
+ r"|\balways\s+" + _CALL_VERB + r"\s+(this|me|it)\b"
123
+ r"|\bmust\s+(always\s+)?be\s+(called|invoked|run|used)\s+(first|before)\b"
124
+ r"|\b(you|the\s+(assistant|model|ai|agent|llm))\s+(must|should|need\s+to|has\s+to|have\s+to|are\s+required\s+to)\s+"
125
+ r"(always\s+)?(first\s+)?(read|include|call|send|run|pass|execute|fetch|invoke|use|ask|tell|forward)\b"
126
+ r"|\bthis\s+is\s+(a\s+)?(required|mandatory|necessary)\s+(first\s+)?step\b",
127
+ re.I,
128
+ )
129
+
130
+ #: A negation right before a match ("never read, open, or echo any .env file").
131
+ _NEGATED_BEFORE = re.compile(r"\b(never|not|no|n't|without|avoid|refuse[sd]?)\s+(\w+\s+)?$", re.I)
132
+
133
+
134
+ def _negated(text: str, start: int) -> bool:
135
+ return bool(_NEGATED_BEFORE.search(text[max(0, start - 30) : start]))
136
+
137
+
138
+ def _path_requested(text: str, start: int) -> bool:
139
+ """A read/send verb shortly before a credential path, and not negated."""
140
+ window_start = max(0, start - 60)
141
+ verb = _PATH_ACTION.search(text[window_start:start])
142
+ return verb is not None and not _negated(text, window_start + verb.start())
143
+
144
+
114
145
  #: A request to move the model's context somewhere (same clause, within a few words).
115
146
  _CONTEXT_EXFIL = re.compile(
116
147
  rf"\b{_SEND_VERB}\b[^.;:!?\n]{{0,60}}?\b{_CONTEXT_NOUN}\b"
@@ -150,6 +181,13 @@ _SENSITIVE_PATHS = re.compile(
150
181
  re.I,
151
182
  )
152
183
 
184
+ #: Verbs that read or move a file; a credential path right after one is a request, not a mention.
185
+ _PATH_ACTION = re.compile(
186
+ r"\b(read|cat|open|load|send|upload|append|include|pass|attach|copy|forward|post|exfiltrate|dump|"
187
+ r"print|echo|output|return|embed)\b[^.;!?\n]{0,40}$",
188
+ re.I,
189
+ )
190
+
153
191
  _URL = re.compile(r"\bhttps?://[^\s<>\"')\]]+", re.I)
154
192
  _EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
155
193
  _BASE64 = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])")
@@ -172,11 +210,41 @@ _ZERO_WIDTH = {
172
210
  _BIDI = set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A))
173
211
  _TAG_BLOCK = range(0xE0000, 0xE0080)
174
212
 
175
- _SHADOW_CONTEXT = re.compile(
176
- r"\b(instead|before|after|when|whenever|always|never|redirect|replace|modify|override|"
177
- r"intercept|wrap|in\s+place\s+of|rather\s+than|prefer|do\s+not\s+use|don't\s+use|ignore)\b",
178
- re.I,
179
- )
213
+ #: Wording that changes how ANOTHER tool behaves. Ordering and preference words ("before",
214
+ #: "after", "always", "prefer", "instead") were dropped in 0.3.1: "call list_projects first"
215
+ #: and "deprecated: use search_v2 instead" are how real servers document workflows.
216
+ _SHADOW_CONTEXT = re.compile(r"\b(redirect|reroute|intercept|override|hijack)\w*\b", re.I)
217
+
218
+
219
+ def _shadows(text: str, name: str) -> bool:
220
+ """True when ``text`` tries to change what the named sibling tool does or whether it is used."""
221
+ tool = rf"[`'\"]?{re.escape(name)}[`'\"]?(\s+(tool|function))?"
222
+ patterns = (
223
+ # "When the send_email tool is used, BCC ..." / "Whenever send_email is called, replace ..."
224
+ rf"\b(when|whenever|each\s+time|every\s+time)\s+(the\s+)?{tool}\s+(is|gets|has\s+been)\s+"
225
+ r"(used|called|invoked|run|executed)\b",
226
+ # "Do not use send_email" / "never call the search tool"
227
+ rf"\b(do\s*n[o']t|never|must\s+not|should\s+not)\s+(use|call|invoke|run)\s+(the\s+)?{tool}",
228
+ # "the send_message tool must route all messages through ..."
229
+ rf"{tool}[^.;!?\n]{{0,40}}\b(must|should|has\s+to|will)\s+(always\s+)?"
230
+ r"(route|send|forward|bcc|cc|copy|include|redirect|use)\b",
231
+ )
232
+ if any(re.search(p, text, re.I) for p in patterns):
233
+ return True
234
+ # Explicit interception wording in the same sentence as the tool name.
235
+ return any(
236
+ re.search(rf"(?<![\w.]){re.escape(name)}(?!\w)", sentence, re.I) and _SHADOW_CONTEXT.search(sentence)
237
+ for sentence in re.split(r"(?<=[.!?])\s+|\n", text)
238
+ )
239
+
240
+
241
+ #: Documentation-only e-mail domains (RFC 2606): examples in parameter docs, not a destination.
242
+ _EXAMPLE_EMAIL = re.compile(r"@([\w-]+\.)*example\.(com|org|net)$|@example$", re.I)
243
+
244
+
245
+ def _identifier_like(name: str) -> bool:
246
+ """A tool name that cannot be an ordinary English word ("search" can; "list_files" cannot)."""
247
+ return bool(re.search(r"[_\-.0-9]|[a-z][A-Z]", name))
180
248
 
181
249
 
182
250
  @dataclass(slots=True)
@@ -246,8 +314,19 @@ def _looks_like_base64(blob: str) -> bool:
246
314
  return printable / max(1, len(decoded)) > 0.8 or len(blob) >= 120
247
315
 
248
316
 
249
- def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: str = "") -> list[Indicator]:
250
- """Run every heuristic over one text and return the indicators found."""
317
+ def analyse_text(
318
+ text: str,
319
+ *,
320
+ other_tool_names: Sequence[str] = (),
321
+ own_name: str = "",
322
+ addressed_to_model: bool = False,
323
+ ) -> list[Indicator]:
324
+ """Run every heuristic over one text and return the indicators found.
325
+
326
+ ``addressed_to_model`` marks text whose purpose is to guide the model (the server's
327
+ ``initialize.instructions``): steering the model between tools is its job there, so
328
+ shadowing wording is only a low-severity review note.
329
+ """
251
330
  indicators: list[Indicator] = []
252
331
  if not text:
253
332
  return indicators
@@ -296,11 +375,18 @@ def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: s
296
375
  ("secret harvesting", _SECRET_HARVEST),
297
376
  ]
298
377
  for label, pattern in patterns:
299
- found = [m.group(0) for m in pattern.finditer(text)]
378
+ found = [m.group(0) for m in pattern.finditer(text) if not _negated(text, m.start())]
300
379
  if found:
301
380
  instruction_kinds.append(label)
302
381
  instruction_matches.extend(found[:3])
303
382
  labelled.extend((label, f) for f in found[:3])
383
+ if instruction_kinds:
384
+ # Ordinary usage guidance only counts alongside a real instruction category.
385
+ weak = [m.group(0) for m in _WEAK_DIRECTIVE.finditer(text)]
386
+ if weak and "model directive" not in instruction_kinds:
387
+ instruction_kinds.append("model directive")
388
+ instruction_matches.extend(weak[:3])
389
+ labelled.extend(("model directive", w) for w in weak[:3])
304
390
  if instruction_kinds:
305
391
  strong = {"conceal from user", "override instructions"} & set(instruction_kinds)
306
392
  severe = len(instruction_kinds) >= 2 or bool(strong)
@@ -326,14 +412,19 @@ def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: s
326
412
  )
327
413
  )
328
414
 
329
- paths = sorted({m.group(0).strip() for m in _SENSITIVE_PATHS.finditer(text)})
415
+ path_hits = [m for m in _SENSITIVE_PATHS.finditer(text)]
416
+ paths = sorted({m.group(0).strip() for m in path_hits})
330
417
  if paths:
418
+ # A credential path after a read/send verb is a request; on its own it is a mention
419
+ # ("paths such as ~/.ssh are refused", "saves the keys to a .env file").
420
+ acted = [m.group(0).strip() for m in path_hits if _path_requested(text, m.start())]
331
421
  indicators.append(
332
422
  Indicator(
333
423
  "sensitive_path",
334
- Severity.MEDIUM,
335
- Confidence.MEDIUM,
336
- "references to credential or configuration file paths",
424
+ Severity.MEDIUM if acted else Severity.LOW,
425
+ Confidence.MEDIUM if acted else Confidence.LOW,
426
+ "references to credential or configuration file paths"
427
+ + (" next to a read/send verb" if acted else ""),
337
428
  paths[:6],
338
429
  )
339
430
  )
@@ -343,37 +434,46 @@ def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: s
343
434
  for name in other_tool_names:
344
435
  if not name or name == own_name or len(name) < 3:
345
436
  continue
346
- if re.search(rf"(?<![\w.]){re.escape(name.lower())}(?!\w)(?!\.\w)", lowered):
437
+ if _identifier_like(name):
438
+ hit = re.search(rf"(?<![\w.]){re.escape(name.lower())}(?!\w)(?!\.\w)", lowered)
439
+ else:
440
+ # A dictionary-word tool name ("search", "pipelines") counts only when quoted.
441
+ hit = re.search(rf"[`'\"]{re.escape(name.lower())}[`'\"]", lowered)
442
+ if hit:
347
443
  refs.append(name)
348
- if refs:
349
- shadowing = bool(_SHADOW_CONTEXT.search(text))
444
+ shadowed = [name for name in refs if _shadows(text, name)]
445
+ if shadowed:
446
+ # A plain mention of a sibling tool ("get the ID from list_projects") is how real
447
+ # servers document workflows and is not reported; changing what it does is.
350
448
  indicators.append(
351
449
  Indicator(
352
450
  "cross_tool_reference",
353
- Severity.MEDIUM if shadowing else Severity.LOW,
354
- Confidence.MEDIUM if shadowing else Confidence.LOW,
355
- "mentions other tools"
356
- + (" with behaviour-modifying language (shadowing)" if shadowing else ""),
357
- refs[:6],
451
+ Severity.LOW if addressed_to_model else Severity.MEDIUM,
452
+ Confidence.LOW if addressed_to_model else Confidence.MEDIUM,
453
+ "mentions other tools with behaviour-modifying language (shadowing)",
454
+ shadowed[:6],
358
455
  )
359
456
  )
360
457
 
361
458
  urls = sorted({m.group(0) for m in _URL.finditer(text)})
362
459
  if urls:
460
+ # Documentation links and example URLs are not reported; a URL next to send/upload
461
+ # wording is a low review note, and medium when the text is about context or secrets.
363
462
  sends = bool(re.search(r"\b(send|post|upload|forward|submit|report)\b", lowered))
364
463
  sensitive = bool(re.search(_CONTEXT_NOUN, text, re.I) or re.search(_SECRET_NOUN, text, re.I))
365
464
  exfil_url = sends and sensitive
366
- indicators.append(
367
- Indicator(
368
- "url",
369
- Severity.MEDIUM if exfil_url else Severity.LOW,
370
- Confidence.MEDIUM if exfil_url else Confidence.LOW,
371
- "URL inside description"
372
- + (" alongside send/upload wording about context or secrets" if exfil_url else ""),
373
- urls[:4],
465
+ if sends:
466
+ indicators.append(
467
+ Indicator(
468
+ "url",
469
+ Severity.MEDIUM if exfil_url else Severity.LOW,
470
+ Confidence.MEDIUM if exfil_url else Confidence.LOW,
471
+ "URL inside description alongside send/upload wording"
472
+ + (" about context or secrets" if exfil_url else ""),
473
+ urls[:4],
474
+ )
374
475
  )
375
- )
376
- emails = sorted({m.group(0) for m in _EMAIL.finditer(text)})
476
+ emails = sorted({m.group(0) for m in _EMAIL.finditer(text) if not _EXAMPLE_EMAIL.search(m.group(0))})
377
477
  if emails and re.search(r"\b(bcc|cc|send|forward|copy)\b", lowered):
378
478
  indicators.append(
379
479
  Indicator(
@@ -467,23 +567,24 @@ class Meta01ToolDescriptionPoisoning(Check):
467
567
  description = (
468
568
  "Scans every tool, prompt and resource description, every inputSchema parameter "
469
569
  "description and the server 'instructions' for: prompt-style markup (<IMPORTANT>, "
470
- "<system>, HTML comments), instructions aimed at the model (call this first / before any "
471
- "other tool, you must, conceal from the user, ignore previous instructions, "
472
- "redirect/replace), requests to send the model's context out (conversation, chat "
473
- "history, system prompt, previous messages), requests to harvest secrets (the user's API "
474
- "key, token, password, credentials, .env), credential file paths, "
475
- "references to other tools with behaviour-modifying language (shadowing), URLs and "
476
- "e-mail addresses with send/BCC wording, base64 blobs, zero-width/bidi/tag Unicode, "
477
- "mixed-script homoglyphs and excessive length. Findings carry tiered confidence and "
478
- "are never rated Critical on heuristics alone. A model directive combined with a request "
479
- "to send context out is rated high. Precision is guarded by a corpus of benign "
480
- "real-world descriptions in the test-suite that must stay below medium."
570
+ "<system>, HTML comments), instructions aimed at the model (conceal from the user, ignore "
571
+ "previous instructions, claim precedence over every other tool or over what the user asks, "
572
+ "redirect/replace), requests to send the model's context out (conversation, chat history, "
573
+ "system prompt, previous messages), requests to harvest secrets (the user's API key, token, "
574
+ "password, credentials, .env), credential file paths next to a read/send verb, wording that "
575
+ "changes what another tool does (shadowing), URLs and e-mail addresses with send/BCC "
576
+ "wording, base64 blobs, zero-width/bidi/tag Unicode, mixed-script homoglyphs and excessive "
577
+ 'length. Ordinary usage guidance ("always use this tool to...", "you must call X '
578
+ 'first") is not an indicator on its own; it raises a finding to high when combined with '
579
+ "another instruction category. Findings carry tiered confidence and are never rated "
580
+ "Critical on heuristics alone. Precision is guarded in the test-suite by thousands of real "
581
+ "descriptions from published MCP servers (docs/benchmark.md)."
481
582
  )
482
583
  evidence_type = "The tools/list (or prompts/resources/initialize) response and the matched excerpts."
483
584
  false_positive_notes = (
484
- "Legitimate guidance such as 'IMPORTANT: call authenticate before this tool' matches the "
485
- "markup and prerequisite heuristics at medium confidence. URLs to documentation and "
486
- "mentions of sibling tools without behaviour-modifying words are reported at low severity. "
585
+ "Legitimate text that claims precedence over every other tool ('call this before any other "
586
+ "tool whenever...') is reported at medium. Plain URLs, sibling-tool mentions and usage "
587
+ "guidance are not reported; a credential path without a read/send verb is low. "
487
588
  "Review the matched excerpts; a second-opinion judge can be plugged in through the Python API (ScanOptions.judge); mcpprobe itself makes no LLM calls."
488
589
  )
489
590
  fix = (
@@ -507,7 +608,12 @@ class Meta01ToolDescriptionPoisoning(Check):
507
608
  "instructions": None,
508
609
  }
509
610
  for item in collect_text_items(ctx):
510
- indicators = analyse_text(item.text, other_tool_names=names, own_name=item.owner)
611
+ indicators = analyse_text(
612
+ item.text,
613
+ other_tool_names=names,
614
+ own_name=item.owner,
615
+ addressed_to_model=item.kind == "instructions",
616
+ )
511
617
  if ctx.options.judge is not None:
512
618
  verdict = ctx.options.judge(item.text, f"tool-description poisoning review for {item.path}")
513
619
  if verdict is not None and verdict.suspicious:
mcpprobe/cli.py CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  ``mcpprobe <target>`` is shorthand for ``mcpprobe scan <target>``.
4
4
 
5
- Exit codes: 0 = no findings at or above ``--fail-on``; 1 = findings at or
6
- above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
5
+ Exit codes: 0 = no finding or attack chain at or above ``--fail-on``; 1 = a
6
+ finding or attack chain at or above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
7
7
  refused, bad arguments).
8
8
  """
9
9
 
@@ -112,7 +112,7 @@ def _parse_kv(values: Sequence[str], what: str, sep: str) -> dict[str, str]:
112
112
  default="high",
113
113
  type=click.Choice(SEVERITIES),
114
114
  show_default=True,
115
- help="Exit 1 if any finding is at or above this severity.",
115
+ help="Exit 1 if any finding or attack chain is at or above this severity.",
116
116
  )
117
117
  @click.option(
118
118
  "--json", "json_path", type=click.Path(dir_okay=False), help="Write the JSON report (with evidence) here."
@@ -331,10 +331,19 @@ def token_cmd(host: str) -> None:
331
331
  click.echo(f' {record_name(host)}. TXT "{expected_txt_record(token)}"')
332
332
 
333
333
 
334
+ def _add_registry_report() -> None:
335
+ from mcpprobe.registry.cli import registry_report
336
+
337
+ cli.add_command(registry_report)
338
+
339
+
340
+ _add_registry_report()
341
+
342
+
334
343
  def main(argv: Sequence[str] | None = None) -> None:
335
344
  """Entry point: default to the ``scan`` sub-command when the first argument is a target."""
336
345
  args = list(sys.argv[1:] if argv is None else argv)
337
- known = {"scan", "checks", "token"}
346
+ known = {"scan", "checks", "token", "registry-report"}
338
347
  group_options = {"-h", "--help", "--version"}
339
348
  if args and args[0] not in known and args[0] not in group_options:
340
349
  args.insert(0, "scan")
mcpprobe/models.py CHANGED
@@ -346,12 +346,23 @@ class ScanReport(BaseModel):
346
346
  notes: list[str] = Field(default_factory=list)
347
347
 
348
348
  def counts(self) -> dict[str, int]:
349
- """Number of findings per severity (all five keys always present)."""
349
+ """Number of findings per severity (all five keys always present).
350
+
351
+ Attack chains are not findings: each one is built from findings already
352
+ counted here, so they are counted separately by :meth:`chain_counts`.
353
+ """
350
354
  out = {s.value: 0 for s in _SEVERITY_ORDER}
351
355
  for f in self.findings:
352
356
  out[f.severity.value] += 1
353
357
  return out
354
358
 
359
+ def chain_counts(self) -> dict[str, int]:
360
+ """Number of attack chains per severity (all five keys always present)."""
361
+ out = {s.value: 0 for s in _SEVERITY_ORDER}
362
+ for c in self.chains:
363
+ out[c.severity.value] += 1
364
+ return out
365
+
355
366
  def coverage(self) -> dict[str, int]:
356
367
  """Check outcomes. ``passed`` counts only checks that ran and found nothing."""
357
368
  out = {
@@ -370,7 +381,18 @@ class ScanReport(BaseModel):
370
381
  return out
371
382
 
372
383
  def max_severity(self) -> Severity | None:
373
- """Highest severity present, or ``None`` when there are no findings."""
384
+ """Highest severity of any finding or attack chain, or ``None`` when there is neither.
385
+
386
+ A chain can be rated above every finding it is built from (CHAIN-01 is critical
387
+ while AUTH-01 and META-03 are high), so it takes part in the pass/fail decision.
388
+ """
389
+ sevs = [f.severity for f in self.findings] + [c.severity for c in self.chains]
390
+ if not sevs:
391
+ return None
392
+ return max(sevs, key=lambda s: s.rank)
393
+
394
+ def findings_max_severity(self) -> Severity | None:
395
+ """Highest severity among findings only, or ``None`` when there are no findings."""
374
396
  if not self.findings:
375
397
  return None
376
398
  return max((f.severity for f in self.findings), key=lambda s: s.rank)
@@ -0,0 +1,31 @@
1
+ """Pipeline for the "State of MCP server security" report over the official MCP Registry.
2
+
3
+ Stages, each resumable from a working directory:
4
+
5
+ 1. :mod:`~mcpprobe.registry.fetch`: page through the registry list API and write
6
+ ``entries.jsonl`` (one normalised entry per server).
7
+ 2. :mod:`~mcpprobe.registry.select`: split entries into remote (``remotes[]`` with a
8
+ streamable-http or SSE URL) and package-only, dedupe hosts, apply exclusions.
9
+ 3. :mod:`~mcpprobe.registry.static`: META-01/06/07 on tool definitions the registry
10
+ entry itself carries (for every entry, including package-only ones).
11
+ 4. :mod:`~mcpprobe.registry.batch`: a polite, passive scan of each remote host with a
12
+ reduced *survey profile* of checks, checkpointed to ``scan.jsonl``.
13
+ 5. :mod:`~mcpprobe.registry.aggregate`: anonymised aggregates with 95% intervals.
14
+ 6. :mod:`~mcpprobe.registry.render`: Markdown report and SVG charts, with a guard that
15
+ refuses to write a report that names a third-party host.
16
+
17
+ Nothing here stores a response body from a third party: only check outcomes, counts and
18
+ the negotiated protocol revision / edge label are kept.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from mcpprobe.registry.common import (
24
+ MAX_CONCURRENCY,
25
+ PER_HOST_INTERVAL,
26
+ STATIC_CHECKS,
27
+ SURVEY_CHECKS,
28
+ Outcome,
29
+ )
30
+
31
+ __all__ = ["MAX_CONCURRENCY", "PER_HOST_INTERVAL", "STATIC_CHECKS", "SURVEY_CHECKS", "Outcome"]
@@ -0,0 +1,5 @@
1
+ """``python -m mcpprobe.registry`` = ``mcpprobe registry-report``."""
2
+
3
+ from mcpprobe.registry.cli import main
4
+
5
+ main()