mcpprobe-scanner 0.3.0__py3-none-any.whl → 0.3.1__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.1"
17
17
 
18
18
  POLICY_URL = "https://mcpprobe.tech/scanning-policy"
19
19
  USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
@@ -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
@@ -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")
@@ -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()