mcpxray-cli 1.0.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.
@@ -0,0 +1,76 @@
1
+ """SARIF 2.1.0 report for GitHub code scanning and other SARIF consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from mcpxray import __version__
9
+ from mcpxray.ir import SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING
10
+
11
+ if TYPE_CHECKING:
12
+ from mcpxray.ir import Diagnostic, McpServer
13
+ from mcpxray.score import ScoreResult
14
+
15
+ # SARIF level mapping: error→error, warning→warning, info→note.
16
+ _LEVEL = {
17
+ SEVERITY_ERROR: "error",
18
+ SEVERITY_WARNING: "warning",
19
+ SEVERITY_INFO: "note",
20
+ }
21
+
22
+
23
+ def render(
24
+ diags: list[Diagnostic],
25
+ doc: McpServer | None = None,
26
+ score_result: ScoreResult | None = None,
27
+ ) -> str:
28
+ rule_index: dict[str, int] = {}
29
+ rules: list[dict] = []
30
+ results: list[dict] = []
31
+
32
+ for d in diags:
33
+ if d.rule_id not in rule_index:
34
+ rule_index[d.rule_id] = len(rules)
35
+ rules.append(
36
+ {
37
+ "id": d.rule_id,
38
+ "defaultConfiguration": {"level": _LEVEL.get(d.severity, "warning")},
39
+ }
40
+ )
41
+ location: dict = {}
42
+ if d.file:
43
+ location = {
44
+ "physicalLocation": {
45
+ "artifactLocation": {"uri": d.file},
46
+ "region": {"startLine": d.line or 1},
47
+ }
48
+ }
49
+ result: dict = {
50
+ "ruleId": d.rule_id,
51
+ "level": _LEVEL.get(d.severity, "warning"),
52
+ "message": {"text": d.message},
53
+ "ruleIndex": rule_index[d.rule_id],
54
+ }
55
+ if location:
56
+ result["locations"] = [location]
57
+ results.append(result)
58
+
59
+ sarif = {
60
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
61
+ "version": "2.1.0",
62
+ "runs": [
63
+ {
64
+ "tool": {
65
+ "driver": {
66
+ "name": "mcpxray",
67
+ "version": __version__,
68
+ "informationUri": "https://github.com/cloudroad-io/mcpxray",
69
+ "rules": rules,
70
+ }
71
+ },
72
+ "results": results,
73
+ }
74
+ ],
75
+ }
76
+ return _json.dumps(sarif, indent=2)
@@ -0,0 +1,8 @@
1
+ """Lint rules for MCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcpxray.rules import builtin # noqa: F401 — registers builtin rules
6
+ from mcpxray.rules.base import Rule, register_rule, rules, run_all
7
+
8
+ __all__ = ["Rule", "register_rule", "rules", "run_all"]
mcpxray/rules/base.py ADDED
@@ -0,0 +1,67 @@
1
+ """Rule API, registry, and runner.
2
+
3
+ A :class:`Rule` inspects a :class:`~mcpxray.ir.McpServer` and yields
4
+ :class:`~mcpxray.ir.Diagnostic` findings. Builtins self-register via
5
+ :func:`register_rule`; external packages declare an entry-point in the
6
+ ``mcpxray.rules`` group. ``run_all`` applies every applicable rule, sorts the
7
+ findings by severity (errors first), and mirrors them onto ``doc.diagnostics``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import Iterable
14
+
15
+ from mcpxray.ir import (
16
+ RISK_MEDIUM,
17
+ SEVERITY_WARNING,
18
+ Diagnostic,
19
+ McpServer,
20
+ severity_rank,
21
+ )
22
+
23
+ # Stable plugin API for rule authors (see CONTRIBUTING.md → "Plugin API
24
+ # stability"). Methods/attributes of ``Rule`` below are part of the contract;
25
+ # ``_RULES`` and any ``_``-prefixed name are internal.
26
+ __all__ = ["Rule", "register_rule", "rules", "run_all"]
27
+
28
+ _RULES: list[type[Rule]] = []
29
+
30
+
31
+ class Rule(ABC):
32
+ """Base class for all lint rules."""
33
+
34
+ id: str = ""
35
+ severity: str = SEVERITY_WARNING
36
+ risk: str = RISK_MEDIUM # scoring weight
37
+
38
+ def applies(self, doc: McpServer) -> bool: # noqa: ARG002
39
+ """Override to gate the rule (e.g. source-only vs manifest-only)."""
40
+ return True
41
+
42
+ @abstractmethod
43
+ def check(self, doc: McpServer) -> Iterable[Diagnostic]:
44
+ """Yield findings for this rule."""
45
+
46
+
47
+ def register_rule(cls: type[Rule]) -> type[Rule]:
48
+ """Class decorator: register a :class:`Rule` subclass."""
49
+ _RULES.append(cls)
50
+ return cls
51
+
52
+
53
+ def rules() -> list[type[Rule]]:
54
+ """All registered rule classes (builtins first, registration order)."""
55
+ return list(_RULES)
56
+
57
+
58
+ def run_all(doc: McpServer) -> list[Diagnostic]:
59
+ """Run every applicable rule; return findings sorted by severity (errors first)."""
60
+ diags: list[Diagnostic] = []
61
+ for cls in _RULES:
62
+ rule = cls()
63
+ if rule.applies(doc):
64
+ diags.extend(rule.check(doc))
65
+ diags.sort(key=lambda d: -severity_rank(d.severity))
66
+ doc.diagnostics.extend(diags)
67
+ return diags
@@ -0,0 +1,5 @@
1
+ """Builtin lint rules. Importing this package registers every rule."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcpxray.rules.builtin import descriptions, schema, source, supply, transport # noqa: F401
@@ -0,0 +1,103 @@
1
+ """Rules over tool descriptions: tool poisoning (MCP101) and hygiene (MCP107)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ from mcpxray.ir import (
9
+ RISK_CRITICAL,
10
+ RISK_LOW,
11
+ SEVERITY_ERROR,
12
+ SEVERITY_WARNING,
13
+ Diagnostic,
14
+ McpServer,
15
+ )
16
+ from mcpxray.rules.base import Rule, register_rule
17
+
18
+ # Indirect-prompt-injection signatures commonly embedded in poisoned tool
19
+ # descriptions (the channel the model trusts most — see Invariant Labs / OWASP).
20
+ _POISON_PATTERNS = [
21
+ re.compile(p, re.IGNORECASE)
22
+ for p in (
23
+ r"ignore\s+(?:all\s+)?previous\s+(?:instructions?|prompts?|rules?)",
24
+ r"disregard\s+(?:all|the|any|previous|above)",
25
+ r"do\s+not\s+(?:show|reveal|display|mention|report)\s+(?:this|the|your)",
26
+ r"instead\s+of\s+(?:responding|answering|following|showing|executing)",
27
+ r"you\s+are\s+(?:now|actually)\s+(?:a|an)\b",
28
+ r"(?:exfiltrate|upload|send|post|transmit|leak).{0,40}?"
29
+ r"(?:to\s+(?:an?\s+)?(?:url|webhook|http|server|email|attacker)|https?://)",
30
+ r"</?(?:system|secret|hidden|implicit)\s*>",
31
+ r"before\s+answering(?:,|:)\s*(?:read|fetch|include|append)",
32
+ )
33
+ ]
34
+
35
+ _DESC_TOO_LONG = 4096 # bloats the context budget on every request
36
+
37
+
38
+ def _hidden_format_chars(text: str) -> list[str]:
39
+ """Zero-width / bidi-override characters invisible to humans but read by the model."""
40
+ return [ch for ch in text if unicodedata.category(ch) == "Cf"]
41
+
42
+
43
+ @register_rule
44
+ class ToolPoisoning(Rule):
45
+ id = "MCP101"
46
+ severity = SEVERITY_ERROR
47
+ risk = RISK_CRITICAL
48
+
49
+ def check(self, doc: McpServer): # type: ignore[override]
50
+ for tool in doc.tools:
51
+ if not tool.description:
52
+ continue
53
+ for pattern in _POISON_PATTERNS:
54
+ for m in pattern.finditer(tool.description):
55
+ yield Diagnostic(
56
+ self.id,
57
+ self.severity,
58
+ f"tool '{tool.name}': description contains a possible prompt-injection "
59
+ f"signature ({m.group(0)!r})",
60
+ tool=tool.name,
61
+ file=tool.source_path,
62
+ line=tool.line,
63
+ )
64
+ hidden = _hidden_format_chars(tool.description)
65
+ if hidden:
66
+ yield Diagnostic(
67
+ self.id,
68
+ self.severity,
69
+ f"tool '{tool.name}': description contains hidden format/bidi characters "
70
+ f"({len(hidden)})",
71
+ tool=tool.name,
72
+ file=tool.source_path,
73
+ line=tool.line,
74
+ )
75
+
76
+
77
+ @register_rule
78
+ class DescriptionHygiene(Rule):
79
+ id = "MCP107"
80
+ severity = SEVERITY_WARNING
81
+ risk = RISK_LOW
82
+
83
+ def check(self, doc: McpServer): # type: ignore[override]
84
+ for tool in doc.tools:
85
+ if not tool.description or not tool.description.strip():
86
+ yield Diagnostic(
87
+ self.id,
88
+ self.severity,
89
+ f"tool '{tool.name}': no description; model can't decide when to call it",
90
+ tool=tool.name,
91
+ file=tool.source_path,
92
+ line=tool.line,
93
+ )
94
+ elif len(tool.description) > _DESC_TOO_LONG:
95
+ yield Diagnostic(
96
+ self.id,
97
+ self.severity,
98
+ f"tool '{tool.name}': description is {len(tool.description)} chars "
99
+ f"(>{_DESC_TOO_LONG}); bloats the context budget on every request",
100
+ tool=tool.name,
101
+ file=tool.source_path,
102
+ line=tool.line,
103
+ )
@@ -0,0 +1,115 @@
1
+ """Schema rules: weak/unvalidatable schemas (MCP104) and strict-client compatibility (MCP106)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcpxray.ir import RISK_MEDIUM, SEVERITY_WARNING, Diagnostic, McpServer
6
+ from mcpxray.rules.base import Rule, register_rule
7
+
8
+ # Keywords strict clients (Cursor, ChatGPT tool-calling) handle poorly or reject.
9
+ _STRICT_INCOMPAT_KEYS = ("$ref", "oneOf", "anyOf", "allOf")
10
+
11
+
12
+ @register_rule
13
+ class WeakSchema(Rule):
14
+ id = "MCP104"
15
+ severity = SEVERITY_WARNING
16
+ risk = RISK_MEDIUM
17
+
18
+ def check(self, doc: McpServer): # type: ignore[override]
19
+ for tool in doc.tools:
20
+ schema = tool.input_schema or {}
21
+ props = schema.get("properties") or {}
22
+ required = schema.get("required") or []
23
+ if props and not required:
24
+ yield Diagnostic(
25
+ self.id,
26
+ self.severity,
27
+ f"tool '{tool.name}': schema has no required parameters -- accepts anything",
28
+ tool=tool.name,
29
+ file=tool.source_path,
30
+ line=tool.line,
31
+ )
32
+ for pname, pschema in props.items():
33
+ if isinstance(pschema, dict) and not pschema:
34
+ yield Diagnostic(
35
+ self.id,
36
+ self.severity,
37
+ f"tool '{tool.name}': parameter '{pname}' has no type -- unvalidatable",
38
+ tool=tool.name,
39
+ file=tool.source_path,
40
+ line=tool.line,
41
+ )
42
+
43
+
44
+ @register_rule
45
+ class SchemaCompatibility(Rule):
46
+ id = "MCP106"
47
+ severity = SEVERITY_WARNING
48
+ risk = RISK_MEDIUM
49
+
50
+ def check(self, doc: McpServer): # type: ignore[override]
51
+ for tool in doc.tools:
52
+ schema = tool.input_schema or {}
53
+ if not isinstance(schema, dict) or not schema:
54
+ continue
55
+ bad = next((k for k in _STRICT_INCOMPAT_KEYS if k in schema), None)
56
+ if bad:
57
+ yield Diagnostic(
58
+ self.id,
59
+ self.severity,
60
+ f"tool '{tool.name}': schema uses '{bad}', which strict clients "
61
+ f"(Cursor/ChatGPT) may reject",
62
+ tool=tool.name,
63
+ file=tool.source_path,
64
+ line=tool.line,
65
+ )
66
+ elif "type" not in schema and "properties" not in schema:
67
+ yield Diagnostic(
68
+ self.id,
69
+ self.severity,
70
+ f"tool '{tool.name}': schema declares no 'type'",
71
+ tool=tool.name,
72
+ file=tool.source_path,
73
+ line=tool.line,
74
+ )
75
+
76
+
77
+ @register_rule
78
+ class SchemaImplDrift(Rule):
79
+ """MCP105 — a tool's declared inputSchema disagrees with its handler.
80
+
81
+ Caught when the handler destructures its arguments (so we know what it reads):
82
+ a parameter the schema declares but the handler ignores (clients must send
83
+ data the tool never uses), or one the handler reads but the schema doesn't
84
+ declare (unvalidated input bypasses the schema). Skipped when
85
+ :attr:`Tool.handler_params` is ``None`` — i.e. a bare ``args`` handler,
86
+ Python (where the schema *is* the signature), or a manifest tool.
87
+ """
88
+
89
+ id = "MCP105"
90
+ severity = SEVERITY_WARNING
91
+ risk = RISK_MEDIUM
92
+
93
+ def check(self, doc: McpServer): # type: ignore[override]
94
+ for tool in doc.tools:
95
+ if tool.handler_params is None:
96
+ continue # can't compare — undeterminable handler signature
97
+ declared = set((tool.input_schema or {}).get("properties") or {})
98
+ used = set(tool.handler_params)
99
+ ignored = sorted(declared - used) # schema declares, handler doesn't read
100
+ unvalidated = sorted(used - declared) # handler reads, schema doesn't validate
101
+ if not (ignored or unvalidated):
102
+ continue
103
+ parts: list[str] = []
104
+ if ignored:
105
+ parts.append(f"schema declares {ignored} but the handler doesn't read them")
106
+ if unvalidated:
107
+ parts.append(f"handler reads {unvalidated} but the schema doesn't declare them")
108
+ yield Diagnostic(
109
+ self.id,
110
+ self.severity,
111
+ f"tool '{tool.name}': " + "; ".join(parts),
112
+ tool=tool.name,
113
+ file=tool.source_path,
114
+ line=tool.line,
115
+ )
@@ -0,0 +1,95 @@
1
+ """Source-scanning rules: secret exposure (MCP102) and dangerous capabilities (MCP103)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from mcpxray.ir import RISK_CRITICAL, RISK_HIGH, SEVERITY_ERROR, Diagnostic, McpServer
8
+ from mcpxray.rules.base import Rule, register_rule
9
+
10
+ # (pattern, human label). Tuned for precision — high-confidence secret shapes.
11
+ _SECRET_PATTERNS: list[tuple[re.Pattern, str]] = [
12
+ (
13
+ re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----"),
14
+ "private key",
15
+ ),
16
+ (re.compile(r"\bsk-[A-Za-z0-9]{20,}"), "OpenAI-style API key"),
17
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key id"),
18
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "GitHub token"),
19
+ (re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"), "GitLab token"),
20
+ (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "Slack token"),
21
+ (re.compile(r"\baiza[0-9A-Za-z_-]{35,}\b"), "Google API key"),
22
+ (
23
+ re.compile(
24
+ r"(?i)(?:api[_-]?key|secret|token|password|passwd)\s*[=:]\s*"
25
+ r"['\"]([A-Za-z0-9_\-+/=]{12,})['\"]"
26
+ ),
27
+ "hardcoded credential",
28
+ ),
29
+ ]
30
+
31
+ # Dangerous primitives an MCP tool should rarely invoke directly.
32
+ _RCE_PATTERNS: list[tuple[re.Pattern, str]] = [
33
+ (re.compile(r"\bos\.system\s*\("), "os.system (arbitrary shell command)"),
34
+ (re.compile(r"\bos\.popen\s*\("), "os.popen (arbitrary shell command)"),
35
+ (re.compile(r"(?<![\w.])eval\s*\("), "eval (arbitrary code execution)"),
36
+ (re.compile(r"(?<![\w.])exec\s*\("), "exec (arbitrary code execution)"),
37
+ (re.compile(r"\bpickle\.loads?\s*\("), "pickle deserialization (RCE risk)"),
38
+ (re.compile(r"\bmarshal\.loads?\s*\("), "marshal deserialization (RCE risk)"),
39
+ (
40
+ re.compile(
41
+ r"subprocess\.(?:run|Popen|call|check_output|check_call)"
42
+ r"\s*\([^)]*?shell\s*=\s*True",
43
+ re.S,
44
+ ),
45
+ "subprocess with shell=True (shell injection risk)",
46
+ ),
47
+ ]
48
+
49
+
50
+ def _line_of(text: str, offset: int) -> int:
51
+ return text.count("\n", 0, offset) + 1
52
+
53
+
54
+ @register_rule
55
+ class SecretExposure(Rule):
56
+ id = "MCP102"
57
+ severity = SEVERITY_ERROR
58
+ risk = RISK_CRITICAL
59
+
60
+ def applies(self, doc: McpServer) -> bool:
61
+ return bool(doc.sources)
62
+
63
+ def check(self, doc: McpServer): # type: ignore[override]
64
+ for path, text in doc.sources.items():
65
+ for pattern, label in _SECRET_PATTERNS:
66
+ for m in pattern.finditer(text):
67
+ yield Diagnostic(
68
+ self.id,
69
+ self.severity,
70
+ f"possible {label} exposed in source",
71
+ file=path,
72
+ line=_line_of(text, m.start()),
73
+ )
74
+
75
+
76
+ @register_rule
77
+ class DangerousCapabilities(Rule):
78
+ id = "MCP103"
79
+ severity = SEVERITY_ERROR
80
+ risk = RISK_HIGH
81
+
82
+ def applies(self, doc: McpServer) -> bool:
83
+ return bool(doc.sources)
84
+
85
+ def check(self, doc: McpServer): # type: ignore[override]
86
+ for path, text in doc.sources.items():
87
+ for pattern, label in _RCE_PATTERNS:
88
+ for m in pattern.finditer(text):
89
+ yield Diagnostic(
90
+ self.id,
91
+ self.severity,
92
+ f"dangerous capability: {label}",
93
+ file=path,
94
+ line=_line_of(text, m.start()),
95
+ )
@@ -0,0 +1,92 @@
1
+ """Supply-chain rule: unpinned dependencies without a lockfile (MCP108)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from mcpxray.fix import exact_pin
8
+ from mcpxray.ir import RISK_MEDIUM, SEVERITY_WARNING, Diagnostic, Fix, McpServer, TextEdit
9
+ from mcpxray.rules.base import Rule, register_rule
10
+
11
+ # npm: an exact bare version is reproducible; carets/tilde/ranges/wildcards/tags
12
+ # drift. Pre-release/build suffixes (``1.2.3-beta.1``) still pin exactly. A bare
13
+ # package name (``flask``) or a pip range (``>=2``) deliberately fails to match.
14
+ _NPM_PIN_RE = re.compile(r"^[v=]?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
15
+
16
+
17
+ def _is_pinned(spec: str) -> bool:
18
+ """True if ``spec`` makes an install reproducible.
19
+
20
+ Covers both ecosystems an MCP server can declare:
21
+ * **pip** — exact ``==1.2.3`` or compatible-release ``~=1.2``.
22
+ * **npm** — an exact bare version ``1.2.3`` / ``v1.2.3`` / ``=1.2.3``.
23
+ Ranges and floating tags (``>=``, ``^``, ``~``, ``*``, ``latest``, ``1.x``)
24
+ are *not* pinned — they resolve differently between installs.
25
+ """
26
+ spec = spec.strip()
27
+ if not spec:
28
+ return False
29
+ if "==" in spec or spec.startswith("~="): # pip
30
+ return True
31
+ return bool(_NPM_PIN_RE.match(spec)) # npm exact version
32
+
33
+
34
+ def _pin_edits(
35
+ dependencies: dict[str, str], unpinned: list[str], dep_file: str, *, pip: bool
36
+ ) -> list[TextEdit]:
37
+ """Literal ``TextEdit``\\s pinning each unpinned dep to its concrete floor.
38
+
39
+ Skips specs with no resolvable floor (``*``/``latest``/bare) or with extras
40
+ / env markers (``name[extra]`` / ``name ; python>'3'``) — rewriting those
41
+ textually is unsafe, so they're left for manual pinning. Each edit anchors on
42
+ the dep name so the fix engine's uniqueness check lands on the right site.
43
+ """
44
+ edits: list[TextEdit] = []
45
+ for name in unpinned:
46
+ spec = dependencies[name]
47
+ if "[" in spec or ";" in spec:
48
+ continue
49
+ pinned = exact_pin(spec, pip=pip)
50
+ if pinned is None:
51
+ continue
52
+ if pip: # pyproject array element: ``"name<spec>"``
53
+ edits.append(TextEdit(old=f"{name}{spec}", new=f"{name}{pinned}"))
54
+ else: # package.json pair: ``"name": "<spec>"``
55
+ edits.append(TextEdit(old=f'"{name}": "{spec}"', new=f'"{name}": "{pinned}"'))
56
+ return edits
57
+
58
+
59
+ @register_rule
60
+ class UnpinnedDependencies(Rule):
61
+ id = "MCP108"
62
+ severity = SEVERITY_WARNING
63
+ risk = RISK_MEDIUM
64
+
65
+ def applies(self, doc: McpServer) -> bool:
66
+ # A lockfile makes installs reproducible regardless of pin style.
67
+ return bool(doc.dependencies) and not doc.lockfiles
68
+
69
+ def check(self, doc: McpServer): # type: ignore[override]
70
+ unpinned = sorted(n for n, spec in doc.dependencies.items() if not _is_pinned(spec))
71
+ if not unpinned:
72
+ return
73
+ shown = ", ".join(unpinned[:5])
74
+ more = f" (+{len(unpinned) - 5} more)" if len(unpinned) > 5 else ""
75
+ diag = Diagnostic(
76
+ self.id,
77
+ self.severity,
78
+ f"{len(unpinned)} unpinned dependencies and no lockfile: {shown}{more}",
79
+ file=doc.meta.path,
80
+ )
81
+ dep_file = doc.dep_file
82
+ if dep_file:
83
+ edits = _pin_edits(
84
+ doc.dependencies, unpinned, dep_file, pip=not dep_file.endswith("package.json")
85
+ )
86
+ if edits:
87
+ diag.fix = Fix(
88
+ description="pin unpinned dependencies to their declared floor version",
89
+ file=dep_file,
90
+ edits=edits,
91
+ )
92
+ yield diag
@@ -0,0 +1,79 @@
1
+ """Transport rule: insecure network transport without TLS / auth (MCP109)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from mcpxray.ir import RISK_MEDIUM, SEVERITY_WARNING, Diagnostic, McpServer
8
+ from mcpxray.rules.base import Rule, register_rule
9
+
10
+ # Construction of an HTTP/SSE-based MCP transport (stdio is local and safe).
11
+ # Covers the TS SDK (StreamableHTTP/SSE transports, ``transport: "http"|"sse"``)
12
+ # and Python FastMCP (``.run(transport="http"|"sse"|"streamable_http")``).
13
+ _HTTP_TRANSPORT_RE = re.compile(
14
+ r"StreamableHTTPServerTransport"
15
+ r"|SSEServerTransport"
16
+ r"|transport\s*[:=]\s*['\"](?:streamable_http|http|sse)['\"]"
17
+ r"|\.(?:connect|serve|start)\s*\(\s*['\"](?:http|sse)['\"]",
18
+ re.IGNORECASE,
19
+ )
20
+
21
+ # A non-loopback plain-HTTP endpoint — transport without TLS.
22
+ _INSECURE_URL_RE = re.compile(r"http://(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)")
23
+
24
+ # Signals that transport is encrypted or authenticated (suppress MCP109).
25
+ _TLS_RE = re.compile(
26
+ r"https://|\bssl\b|\btls\b|createSecureContext|SecureServer|SSLContext",
27
+ re.IGNORECASE,
28
+ )
29
+ _AUTH_RE = re.compile(
30
+ r"authenticate|authorization|bearer|api[_-]?key|authmiddleware|verifytoken"
31
+ r"|requires_auth|httpbasic|oauth|verify_token|@authenticated",
32
+ re.IGNORECASE,
33
+ )
34
+
35
+
36
+ @register_rule
37
+ class InsecureTransport(Rule):
38
+ """MCP109 — an HTTP/SSE transport exposed without TLS or authentication.
39
+
40
+ Only inspected when the server actually constructs a network transport
41
+ (``stdio`` is local, so it's out of scope). Two independent warnings:
42
+
43
+ * a non-loopback ``http://`` endpoint and no TLS anywhere → *no encryption*;
44
+ * no auth primitive anywhere → *unauthenticated* (any reachable client can
45
+ invoke the tools).
46
+
47
+ Both are warnings: a reverse proxy may legitimately provide TLS/auth, so the
48
+ finding is surfaced for review rather than treated as a hard error.
49
+ """
50
+
51
+ id = "MCP109"
52
+ severity = SEVERITY_WARNING
53
+ risk = RISK_MEDIUM
54
+
55
+ def applies(self, doc: McpServer) -> bool:
56
+ return bool(doc.sources)
57
+
58
+ def check(self, doc: McpServer): # type: ignore[override]
59
+ blob = "\n".join(doc.sources.values())
60
+ if not _HTTP_TRANSPORT_RE.search(blob):
61
+ return # stdio / no transport — nothing to flag
62
+ has_tls = bool(_TLS_RE.search(blob))
63
+ has_auth = bool(_AUTH_RE.search(blob))
64
+ if _INSECURE_URL_RE.search(blob) and not has_tls:
65
+ yield Diagnostic(
66
+ self.id,
67
+ self.severity,
68
+ "HTTP transport endpoint without TLS — use https so tool traffic "
69
+ "isn't sent in the clear",
70
+ file=doc.meta.path,
71
+ )
72
+ if not has_auth:
73
+ yield Diagnostic(
74
+ self.id,
75
+ self.severity,
76
+ "network transport exposed without authentication — any reachable "
77
+ "client can invoke these tools",
78
+ file=doc.meta.path,
79
+ )