mcpprobe-scanner 0.2.0__py3-none-any.whl → 0.3.0__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.2.0"
16
+ __version__ = "0.3.0"
17
17
 
18
18
  POLICY_URL = "https://mcpprobe.tech/scanning-policy"
19
19
  USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
mcpprobe/action.py CHANGED
@@ -2,19 +2,40 @@
2
2
 
3
3
  GitHub passes action inputs as ``INPUT_<NAME>`` environment variables (name
4
4
  upper-cased, hyphens kept). This module turns them into a CLI invocation,
5
- always writes SARIF, records outputs in ``$GITHUB_OUTPUT`` and exits with the
6
- scanner's exit code (0 clean, 1 findings at or above ``fail-on``, 2 error).
5
+ always writes SARIF, records outputs in ``$GITHUB_OUTPUT``, writes a job
6
+ summary to ``$GITHUB_STEP_SUMMARY``, emits one workflow annotation that names
7
+ the outcome, and exits with the scanner's exit code:
8
+
9
+ - 0 clean (no finding at or above ``fail-on``)
10
+ - 1 findings at or above ``fail-on``
11
+ - 2 error (unreachable target, bad input, opt-out, authorisation refused)
12
+ - 3 the target answered but is not an MCP server (wrong URL or path)
13
+
14
+ For exit codes 2 and 3 the SARIF file contains no results. Uploading it to
15
+ code scanning would close every open alert for the category, so workflows
16
+ should upload only when ``result`` is ``clean`` or ``findings``.
7
17
  """
8
18
 
9
19
  from __future__ import annotations
10
20
 
21
+ import json
11
22
  import os
12
23
  import shlex
13
24
  import sys
14
25
  from collections.abc import Mapping
15
26
  from pathlib import Path
27
+ from typing import Any
16
28
 
17
- from mcpprobe.cli import EXIT_ERROR, main
29
+ from mcpprobe.cli import EXIT_CLEAN, EXIT_ERROR, EXIT_FINDINGS, EXIT_NOT_MCP, main
30
+
31
+ #: ``result`` output value for each exit code.
32
+ RESULTS: dict[int, str] = {
33
+ EXIT_CLEAN: "clean",
34
+ EXIT_FINDINGS: "findings",
35
+ EXIT_ERROR: "error",
36
+ EXIT_NOT_MCP: "not-mcp",
37
+ }
38
+ _SEVERITIES = ("critical", "high", "medium", "low", "info")
18
39
 
19
40
 
20
41
  def _input(env: Mapping[str, str], name: str, default: str = "") -> str:
@@ -44,24 +65,110 @@ def build_argv(env: Mapping[str, str]) -> list[str]:
44
65
  return argv
45
66
 
46
67
 
68
+ def _escape(text: str) -> str:
69
+ """Escape a workflow-command message (``%``, CR and LF must be encoded)."""
70
+ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
71
+
72
+
73
+ def _escape_property(text: str) -> str:
74
+ """Escape a workflow-command property value (also ``:`` and ``,``)."""
75
+ return _escape(text).replace(":", "%3A").replace(",", "%2C")
76
+
77
+
78
+ def sarif_counts(path: Path) -> dict[str, int]:
79
+ """Count SARIF results per mcpprobe severity (empty when the file is missing or unreadable)."""
80
+ counts = dict.fromkeys(_SEVERITIES, 0)
81
+ try:
82
+ data: Any = json.loads(path.read_text(encoding="utf-8"))
83
+ for run in data.get("runs", []):
84
+ for result in run.get("results", []):
85
+ sev = str(result.get("properties", {}).get("severity", "info"))
86
+ counts[sev if sev in counts else "info"] += 1
87
+ except (OSError, ValueError, AttributeError):
88
+ return {}
89
+ return counts
90
+
91
+
92
+ def describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) -> tuple[str, str, str]:
93
+ """Return (annotation level, title, message) for an exit code."""
94
+ total = sum(counts.values())
95
+ breakdown = ", ".join(f"{counts[s]} {s}" for s in _SEVERITIES if counts.get(s))
96
+ 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}."
99
+ if code == EXIT_FINDINGS:
100
+ return (
101
+ "error",
102
+ "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.",
105
+ )
106
+ if code == EXIT_NOT_MCP:
107
+ return (
108
+ "error",
109
+ "mcpprobe: not an MCP server",
110
+ f"{target} answered, but not as an MCP server, so nothing was checked and nothing passed. "
111
+ "Check the URL and path (Streamable HTTP endpoints are usually /mcp) and that the "
112
+ "deployment is up. Do not upload this SARIF: it has no results and would close open alerts.",
113
+ )
114
+ return (
115
+ "error",
116
+ "mcpprobe: scan error",
117
+ f"The scan of {target} did not complete (exit {code}): the target was unreachable, an input "
118
+ "was invalid, the target opted out, or authorisation was refused. See the step log.",
119
+ )
120
+
121
+
122
+ def _summary(code: int, target: str, fail_on: str, counts: Mapping[str, int], message: str) -> str:
123
+ lines = [
124
+ "## mcpprobe MCP security scan",
125
+ "",
126
+ f"**Target:** `{target}` ",
127
+ f"**Result:** {RESULTS.get(code, 'error')} (exit {code}, fail-on `{fail_on}`)",
128
+ "",
129
+ message,
130
+ "",
131
+ ]
132
+ if code in (EXIT_CLEAN, EXIT_FINDINGS) and counts:
133
+ lines += ["| Severity | Findings |", "|---|---|"]
134
+ lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
135
+ lines.append("")
136
+ lines.append(
137
+ "Docs: https://mcpprobe.tech · checks: https://github.com/mcpprobe-dev/mcpprobe/blob/main/CHECKS.md"
138
+ )
139
+ return "\n".join(lines) + "\n"
140
+
141
+
47
142
  def run(env: Mapping[str, str] | None = None) -> int:
48
143
  env = os.environ if env is None else env
49
144
  try:
50
145
  argv = build_argv(env)
51
146
  except ValueError as exc:
52
- print(f"::error::{exc}", file=sys.stderr)
147
+ print(
148
+ f"::error title={_escape_property('mcpprobe: bad input')}::{_escape(str(exc))}", file=sys.stderr
149
+ )
53
150
  return EXIT_ERROR
54
151
  code = EXIT_ERROR
55
152
  try:
56
153
  main(argv)
57
154
  except SystemExit as exc:
58
155
  code = exc.code if isinstance(exc.code, int) else EXIT_ERROR
156
+ target = argv[1]
157
+ fail_on = argv[argv.index("--fail-on") + 1]
158
+ sarif = argv[argv.index("--sarif") + 1]
159
+ counts = sarif_counts(Path(sarif))
160
+ level, title, message = describe(code, target, fail_on, counts)
161
+ print(f"::{level} title={_escape_property(title)}::{_escape(message)}", file=sys.stderr)
59
162
  out = env.get("GITHUB_OUTPUT")
60
163
  if out:
61
- sarif = argv[argv.index("--sarif") + 1]
62
164
  with Path(out).open("a", encoding="utf-8") as fh:
63
- fh.write(f"sarif-file={sarif}\n")
165
+ fh.write(f"sarif-file={sarif if Path(sarif).exists() else ''}\n")
64
166
  fh.write(f"exit-code={code}\n")
167
+ fh.write(f"result={RESULTS.get(code, 'error')}\n")
168
+ summary = env.get("GITHUB_STEP_SUMMARY")
169
+ if summary:
170
+ with Path(summary).open("a", encoding="utf-8") as fh:
171
+ fh.write(_summary(code, target, fail_on, counts, message))
65
172
  return code
66
173
 
67
174
 
@@ -31,7 +31,16 @@ from mcpprobe.checks.metadata import (
31
31
  Meta05SecretsInMetadata,
32
32
  )
33
33
  from mcpprobe.checks.poisoning import Meta01ToolDescriptionPoisoning
34
+ from mcpprobe.checks.protocol_2026 import (
35
+ Auth05IssuerIdentification,
36
+ Auth06DynamicClientRegistration,
37
+ Trans06ServerDiscover,
38
+ Trans07UnadvertisedVersionAccepted,
39
+ Trans08HeaderBodyMismatch,
40
+ Trans09CachingHints,
41
+ )
34
42
  from mcpprobe.checks.robustness import Dos01UnboundedConsumption, Err01VerboseErrors
43
+ from mcpprobe.checks.tool_surface import Meta06ToolAnnotationLint, Meta07ToxicFlow
35
44
  from mcpprobe.checks.transport_conformance import (
36
45
  Trans01OriginValidation,
37
46
  Trans02ProtocolVersionHandling,
@@ -45,16 +54,24 @@ PASSIVE_CHECKS: list[type[Check]] = [
45
54
  Auth02MissingProtectedResourceMetadata,
46
55
  Auth03AuthorizationServerMetadata,
47
56
  Auth04TokenAudienceAndPassthrough,
57
+ Auth05IssuerIdentification,
58
+ Auth06DynamicClientRegistration,
48
59
  Trans01OriginValidation,
49
60
  Trans02ProtocolVersionHandling,
50
61
  Trans03SessionIdQuality,
51
62
  Trans04DeprecatedTransport,
52
63
  Trans05TlsAndHeaders,
64
+ Trans06ServerDiscover,
65
+ Trans07UnadvertisedVersionAccepted,
66
+ Trans08HeaderBodyMismatch,
67
+ Trans09CachingHints,
53
68
  Meta01ToolDescriptionPoisoning,
54
69
  Meta02RugPullBaseline,
55
70
  Meta03DangerousCapabilitySurface,
56
71
  Meta04SchemaHygiene,
57
72
  Meta05SecretsInMetadata,
73
+ Meta06ToolAnnotationLint,
74
+ Meta07ToxicFlow,
58
75
  Err01VerboseErrors,
59
76
  Dos01UnboundedConsumption,
60
77
  ]
mcpprobe/checks/auth.py CHANGED
@@ -461,6 +461,10 @@ class Auth03AuthorizationServerMetadata(Check):
461
461
  )
462
462
  continue
463
463
  doc_proof = f"GET {doc_url} -> HTTP 200\n" + proof.indent(proof.pretty(doc))
464
+ # Shared with AUTH-05/AUTH-06, which read the same document and send nothing.
465
+ ctx.shared.setdefault("as_metadata", []).append(
466
+ {"issuer": issuer, "url": doc_url, "doc": doc, "evidence": responses[-1].evidence}
467
+ )
464
468
  methods = doc.get("code_challenge_methods_supported")
465
469
  if not isinstance(methods, list) or "S256" not in methods:
466
470
  findings.append(
@@ -524,7 +528,8 @@ class Auth03AuthorizationServerMetadata(Check):
524
528
  if assessed == 0:
525
529
  raise NotApplicable(
526
530
  "Every advertised authorization server is on a third-party host that was not "
527
- "contacted (--no-follow-as or the host opted out); see scan notes."
531
+ "contacted (--no-follow-as or the host opted out); see scan notes.",
532
+ blocked=True,
528
533
  )
529
534
  return findings
530
535
 
@@ -690,7 +695,8 @@ class Auth04TokenAudienceAndPassthrough(Check):
690
695
  raise NotApplicable(
691
696
  "The forged-token probes were answered with "
692
697
  + ", ".join(f"HTTP {s}" if s else "no response" for s in statuses)
693
- + " rather than 401/403, so token validation could not be observed."
698
+ + " rather than 401/403, so token validation could not be observed.",
699
+ blocked=True,
694
700
  )
695
701
  return findings
696
702
 
mcpprobe/checks/base.py CHANGED
@@ -44,10 +44,13 @@ class NotApplicable(Exception): # noqa: N818 - it is an outcome, not an error
44
44
  (e.g. ``"AUTH-01"``). A not-applicable check never counts as a pass.
45
45
  """
46
46
 
47
- def __init__(self, reason: str, related_to: str | None = None) -> None:
47
+ def __init__(self, reason: str, related_to: str | None = None, *, blocked: bool = False) -> None:
48
48
  super().__init__(reason)
49
49
  self.reason = reason
50
50
  self.related_to = related_to
51
+ #: True when the behaviour exists but could not be observed (authentication,
52
+ #: rate limiting, a fetch the operator disabled), as opposed to being absent.
53
+ self.blocked = blocked
51
54
 
52
55
 
53
56
  @dataclass(slots=True)
@@ -140,9 +143,10 @@ class ScanContext:
140
143
  "tools/list requires authentication and no credentials were supplied, so no tool "
141
144
  "metadata could be read; re-run with --header 'Authorization: Bearer ...' to assess it.",
142
145
  related_to=None,
146
+ blocked=True,
143
147
  )
144
148
  if inv.tools_error:
145
- raise NotApplicable(f"the tool list could not be read ({inv.tools_error}).")
149
+ raise NotApplicable(f"the tool list could not be read ({inv.tools_error}).", blocked=True)
146
150
  raise NotApplicable(
147
151
  "the server lists no tools, prompts or resources, so there is no metadata to assess."
148
152
  )
@@ -166,6 +170,9 @@ class Check(abc.ABC):
166
170
  false_positive_notes: str = ""
167
171
  #: Default remediation text.
168
172
  fix: str = ""
173
+ #: When False (the default) a check that met an HTTP 429 the scanner could not wait
174
+ #: out is reported not applicable instead of drawing conclusions from it.
175
+ handles_rate_limit: bool = False
169
176
 
170
177
  @abc.abstractmethod
171
178
  async def run(self, ctx: ScanContext) -> list[Finding]:
@@ -247,3 +247,109 @@ def arbitrary_capabilities(tool: dict[str, Any]) -> dict[str, str]:
247
247
 
248
248
  def classify_tools(tools: list[dict[str, Any]]) -> dict[str, Classification]:
249
249
  return {str(t.get("name", "")): classify_tool(t) for t in tools}
250
+
251
+
252
+ # ----------------------------------------------------------------------------- toxic-flow roles (META-07)
253
+ ROLE_PRIVATE = "private_data"
254
+ ROLE_UNTRUSTED = "untrusted_content"
255
+ ROLE_SEND = "external_send"
256
+
257
+ #: Reading mail or messages: private data and, at the same time, content written by strangers.
258
+ _INBOX_READ = re.compile(
259
+ r"\b(inbox|mailbox|unread|read\s*_?(e-?mails?|mail|messages?)|"
260
+ r"(list|get|search|fetch|retrieve)\s*_?(e-?mails?|mail|messages?))\b",
261
+ re.I,
262
+ )
263
+ #: Pulling content from the open web.
264
+ _WEB_INTAKE = re.compile(
265
+ r"\b(fetch|browse|crawl|scrape|download|web\s*_?search|search\s*_?(the\s+)?web|"
266
+ r"(read|get|open|load)\s*_?(a\s+|the\s+)?(url|web\s*page|page|website)|rss|feed)\b",
267
+ re.I,
268
+ )
269
+ #: Moving data out of the server's own boundary.
270
+ _SEND_OUT = re.compile(
271
+ r"\b(send|sends|post|posts|publish|tweet|webhook|notify|upload|transmit|forward|reply|"
272
+ r"smtp|share|e-?mail\s+(to|a|an)\b)",
273
+ re.I,
274
+ )
275
+ #: Verbs that make an e-mail or payment tool a write rather than a read.
276
+ _MUTATING = re.compile(
277
+ r"\b(send|sends|post|charge|refund|pay|transfer|create|cancel|delete|update|write|forward|reply)\b",
278
+ re.I,
279
+ )
280
+
281
+ #: Classes that change or destroy state on their own (no verb needed).
282
+ MUTATING_CLASSES: frozenset[str] = frozenset({"exec", "file_write", "database_write", "delete"})
283
+ #: Classes that are writes only when a mutating verb is present.
284
+ VERB_MUTATING_CLASSES: frozenset[str] = frozenset({"email", "payments"})
285
+ _PRIVATE_LABEL = {
286
+ "file_read": "files",
287
+ "database_read": "a database",
288
+ "database_write": "a database",
289
+ "credentials": "credentials",
290
+ }
291
+
292
+ #: Classes that only read.
293
+ READ_CLASSES: frozenset[str] = frozenset({"search", "file_read", "database_read"})
294
+
295
+
296
+ def _plain_text(tool: dict[str, Any]) -> str:
297
+ """Name (with separators as spaces), title and description; parameter names excluded."""
298
+ name = str(tool.get("name", ""))
299
+ spaced = name.replace("_", " ").replace("-", " ").replace(".", " ")
300
+ return " ".join([name, spaced, str(tool.get("title") or ""), str(tool.get("description") or "")])
301
+
302
+
303
+ def looks_mutating(tool: dict[str, Any], cls: Classification) -> bool:
304
+ """Whether the advertised capability changes state (used for annotation contradictions)."""
305
+ if ARBITRARY_EXEC in cls.arbitrary or ARBITRARY_FILE_WRITE in cls.arbitrary:
306
+ return True
307
+ if any(c in MUTATING_CLASSES for c in cls.classes):
308
+ return True
309
+ return any(c in VERB_MUTATING_CLASSES for c in cls.classes) and bool(_MUTATING.search(_plain_text(tool)))
310
+
311
+
312
+ def looks_destructive(cls: Classification) -> bool:
313
+ """Execution, deletion and overwriting writes (destructiveHint:false would be wrong)."""
314
+ return (
315
+ ARBITRARY_EXEC in cls.arbitrary
316
+ or ARBITRARY_FILE_WRITE in cls.arbitrary
317
+ or any(c in MUTATING_CLASSES for c in cls.classes)
318
+ )
319
+
320
+
321
+ def looks_read_only(tool: dict[str, Any], cls: Classification) -> bool:
322
+ """Only read classes matched and nothing suggests a write."""
323
+ return bool(cls.classes) and set(cls.classes) <= READ_CLASSES and not looks_mutating(tool, cls)
324
+
325
+
326
+ def toxic_roles(tool: dict[str, Any], cls: Classification) -> dict[str, str]:
327
+ """Lethal-trifecta roles a tool can play, each with the reason (from its advertised metadata).
328
+
329
+ Arbitrary command execution plays all three (a shell reads local data, fetches
330
+ content and sends it anywhere); arbitrary file read is private data.
331
+ """
332
+ text = _plain_text(tool)
333
+ roles: dict[str, str] = {}
334
+ if ARBITRARY_EXEC in cls.arbitrary:
335
+ why = "arbitrary command execution (see META-03)"
336
+ return {ROLE_PRIVATE: why, ROLE_UNTRUSTED: why, ROLE_SEND: why}
337
+ classes = set(cls.classes)
338
+ if ARBITRARY_FILE_READ in cls.arbitrary:
339
+ roles[ROLE_PRIVATE] = "arbitrary file read (see META-03)"
340
+ elif classes & set(_PRIVATE_LABEL):
341
+ labels = sorted({_PRIVATE_LABEL[c] for c in classes & set(_PRIVATE_LABEL)})
342
+ roles[ROLE_PRIVATE] = "accesses " + " and ".join(labels)
343
+ inbox = _INBOX_READ.search(text) if "email" in classes else None
344
+ if inbox:
345
+ roles.setdefault(ROLE_PRIVATE, f"reads mail or messages ('{inbox.group(0)}')")
346
+ roles[ROLE_UNTRUSTED] = f"returns mail or messages written by others ('{inbox.group(0)}')"
347
+ if "network" in classes:
348
+ web = _WEB_INTAKE.search(text)
349
+ if web:
350
+ roles[ROLE_UNTRUSTED] = f"pulls content from the web ('{web.group(0)}')"
351
+ if classes & {"email", "network"}:
352
+ out = _SEND_OUT.search(text)
353
+ if out:
354
+ roles[ROLE_SEND] = f"sends data out ('{out.group(0).strip()}')"
355
+ return roles
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import re
7
6
  from typing import Any
8
7
 
@@ -14,7 +13,6 @@ from mcpprobe.checks.capability import (
14
13
  ARBITRARY_FILE_READ,
15
14
  ARBITRARY_FILE_WRITE,
16
15
  DANGEROUS_CLASSES,
17
- Classification,
18
16
  classify_tools,
19
17
  )
20
18
  from mcpprobe.checks.poisoning import collect_text_items
@@ -39,10 +37,13 @@ _CAPABILITY_LABEL = {
39
37
  def _tools_verify(ctx: ScanContext, jq: str, expect: str, check_id: str) -> str:
40
38
  if ctx.http is None:
41
39
  return proof.rescan(ctx.target, check_id, stdio=True)
40
+ modern = ctx.fingerprint.flavour == "stateless-2026"
42
41
  return proof.curl_rpc(
43
42
  ctx.target,
44
43
  "tools/list",
45
- {},
44
+ proof.modern_params() if modern else {},
45
+ protocol_version="2026-07-28" if modern else "2025-11-25",
46
+ headers={"Mcp-Method": "tools/list"} if modern else None,
46
47
  include_headers=False,
47
48
  auth_placeholder=ctx.fingerprint.credentials_supplied,
48
49
  jq=jq,
@@ -58,7 +59,7 @@ def _require_tools(ctx: ScanContext) -> None:
58
59
 
59
60
  class Meta03DangerousCapabilitySurface(Check):
60
61
  id = "META-03"
61
- title = "Dangerous tool capability exposed or annotations contradict it"
62
+ title = "Dangerous tool capability exposed"
62
63
  severity = Severity.HIGH
63
64
  standards = Standards(
64
65
  mcp=["MCP02:2025", "MCP05:2025"],
@@ -71,9 +72,9 @@ class Meta03DangerousCapabilitySurface(Check):
71
72
  "Tools that advertise arbitrary command execution, arbitrary file read or arbitrary file "
72
73
  "write (a shell/file capability selected by a free-form string argument with no enum, "
73
74
  "const or pattern) are rated high on their own and critical when the remote endpoint "
74
- "needs no authentication. Declared annotations are cross-checked (a command tool marked "
75
- "readOnlyHint:true is reported), and the remaining high-impact surface (network, e-mail, "
76
- "credentials, payments) is summarised at low severity. Passive scanning sees the "
75
+ "needs no authentication. The remaining high-impact surface (network, e-mail, "
76
+ "credentials, payments) is summarised at low severity. Annotations that contradict the "
77
+ "capability are reported by META-06 (until 0.2 they were reported here). Passive scanning sees the "
77
78
  "advertised capability, not whether the implementation really executes: the rating is "
78
79
  "based on the name, description and schema, and no tool is called."
79
80
  )
@@ -87,8 +88,7 @@ class Meta03DangerousCapabilitySurface(Check):
87
88
  fix = (
88
89
  "For arbitrary command execution or file access: remove the tool, or sandbox it and "
89
90
  "replace the free-form argument with an allow-list (enum) of commands or files. "
90
- "Annotations come after that. Make annotations truthful (destructiveHint:true for "
91
- "anything that executes or deletes); they help a careful client warn the user but do "
91
+ "Annotations come after that (META-06); they help a careful client warn the user but do "
92
92
  "nothing about the risk."
93
93
  )
94
94
 
@@ -193,78 +193,7 @@ class Meta03DangerousCapabilitySurface(Check):
193
193
  )
194
194
  )
195
195
 
196
- # 2. Annotation contradictions (one finding listing every affected tool).
197
- contradictions: dict[str, list[str]] = {}
198
- for tool in ctx.inventory.tools:
199
- name = str(tool.get("name", "?"))
200
- cls: Classification = classes.get(name) or Classification(name=name)
201
- ann = tool.get("annotations")
202
- if not isinstance(ann, dict):
203
- continue
204
- problems: list[str] = []
205
- looks = "command execution" if cls.arbitrary.get(ARBITRARY_EXEC) else cls.primary
206
- if ann.get("readOnlyHint") is True and (cls.writes or ARBITRARY_EXEC in cls.arbitrary):
207
- problems.append(f"readOnlyHint=true but tool looks like {looks}")
208
- if ann.get("destructiveHint") is False and (
209
- any(c in {"exec", "delete", "file_write", "database_write"} for c in cls.classes)
210
- or ARBITRARY_EXEC in cls.arbitrary
211
- ):
212
- problems.append(f"destructiveHint=false but tool looks like {looks}")
213
- if ann.get("openWorldHint") is False and "network" in cls.classes:
214
- problems.append("openWorldHint=false but tool appears to make network requests")
215
- if problems:
216
- contradictions[name] = problems
217
- if contradictions:
218
- names = sorted(contradictions)
219
- findings.append(
220
- self.finding(
221
- title="Tool annotations contradict the tool's apparent capability",
222
- description="; ".join(f"Tool '{n}': " + "; ".join(contradictions[n]) for n in names)
223
- + ". Clients that trust annotations may skip the user's approval for these tools.",
224
- severity=Severity.MEDIUM,
225
- confidence=Confidence.MEDIUM,
226
- confidence_reason=(
227
- "The capability is inferred from keywords in the name, description and "
228
- "parameter names, which can misclassify a narrowly scoped tool."
229
- ),
230
- evidence=ev,
231
- excerpt=evidence_summary(
232
- ev, "; ".join(f"{n}: annotations={by_name[n].get('annotations')}" for n in names)
233
- ),
234
- location="tools[*].annotations",
235
- details={
236
- "tools": {
237
- n: {
238
- "problems": contradictions[n],
239
- "annotations": by_name[n].get("annotations"),
240
- "classes": classes[n].classes,
241
- "matched_terms": classes[n].matched_terms,
242
- }
243
- for n in names
244
- }
245
- },
246
- fix=(
247
- "Make annotations truthful: readOnlyHint only for tools with no side effects, "
248
- "destructiveHint:true for anything that deletes or executes, openWorldHint:true "
249
- "for tools that reach the network."
250
- ),
251
- affected=names,
252
- proof="\n\n".join(
253
- f"tools[{n}]:\n description: {by_name[n].get('description', '')}\n"
254
- f" annotations: {json.dumps(by_name[n].get('annotations'))}\n"
255
- + "\n".join(f" - {p}" for p in contradictions[n])
256
- for n in names
257
- ),
258
- verify=_tools_verify(
259
- ctx,
260
- ".result.tools[] | {name, annotations}",
261
- "annotations that match each tool's behaviour",
262
- self.id,
263
- ),
264
- )
265
- )
266
-
267
- # 3. Remaining high-impact surface (informational review prompt).
196
+ # 2. Remaining high-impact surface (informational review prompt).
268
197
  rest = {n: c.classes for n, c in classes.items() if c.dangerous and n not in arbitrary}
269
198
  by_class: dict[str, list[str]] = {}
270
199
  for n, cl in rest.items():
@@ -595,10 +595,13 @@ def _verify(ctx: ScanContext, item: TextItem) -> str:
595
595
  return proof.rescan(ctx.target, "META-01", stdio=True)
596
596
  if item.kind in {"tool", "tool_param"}:
597
597
  jq = f'.result.tools[] | select(.name=="{item.owner}") | {{description, inputSchema}}'
598
+ modern = ctx.fingerprint.flavour == "stateless-2026"
598
599
  return proof.curl_rpc(
599
600
  ctx.target,
600
601
  "tools/list",
601
- {},
602
+ proof.modern_params() if modern else {},
603
+ protocol_version="2026-07-28" if modern else "2025-11-25",
604
+ headers={"Mcp-Method": "tools/list"} if modern else None,
602
605
  include_headers=False,
603
606
  auth_placeholder=ctx.fingerprint.credentials_supplied,
604
607
  jq=jq,
mcpprobe/checks/proof.py CHANGED
@@ -144,6 +144,15 @@ def curl_rpc(
144
144
  return line
145
145
 
146
146
 
147
+ def modern_params(version: str = "2026-07-28", **extra: Any) -> dict[str, Any]:
148
+ """Params for a 2026-07-28 request: the required ``_meta`` plus ``extra``."""
149
+ meta = {
150
+ "io.modelcontextprotocol/protocolVersion": version,
151
+ "io.modelcontextprotocol/clientCapabilities": {},
152
+ }
153
+ return {**extra, "_meta": meta}
154
+
155
+
147
156
  def initialize_params(version: str = "2025-11-25") -> dict[str, Any]:
148
157
  return {"protocolVersion": version, "capabilities": {}, "clientInfo": VERIFY_CLIENT}
149
158