mcphound 0.1.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.
mcphound/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mcphound — security scanner for MCP servers and agent skills."""
2
+
3
+ __version__ = "0.1.0"
mcphound/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """mcphound CLI — scan and inspect MCP configurations without executing servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from .discovery.clients import discover_configs, load_servers
12
+ from .models import SEVERITY_ORDER, ScanResult
13
+ from .output import to_json, to_sarif
14
+ from .rules.engine import evaluate
15
+ from .rules.loader import load_rules
16
+
17
+ app = typer.Typer(
18
+ add_completion=False,
19
+ help="mcphound — security scanner for MCP servers and agent skills.",
20
+ )
21
+
22
+
23
+ def _collect(paths: list[Path] | None, deep: bool = False) -> ScanResult:
24
+ config_paths = paths or discover_configs()
25
+ servers = []
26
+ for p in config_paths:
27
+ if p.exists():
28
+ servers.extend(load_servers(p))
29
+ rules = load_rules()
30
+ if not deep:
31
+ rules = [r for r in rules if not r.get("network")]
32
+ findings = []
33
+ for server in servers:
34
+ findings.extend(evaluate(server, rules))
35
+ return ScanResult(
36
+ targets=[str(p) for p in config_paths],
37
+ servers=servers,
38
+ findings=findings,
39
+ )
40
+
41
+
42
+ @app.command()
43
+ def inspect(
44
+ config: Annotated[
45
+ Path | None, typer.Argument(help="Config file; defaults to auto-discovery")
46
+ ] = None,
47
+ ):
48
+ """List configured MCP servers WITHOUT executing them."""
49
+ paths = [config] if config else discover_configs()
50
+ existing = [p for p in paths if p.exists()]
51
+ if not existing:
52
+ typer.echo("No MCP configurations found.")
53
+ raise typer.Exit(0)
54
+ for p in existing:
55
+ typer.echo(f"\n# {p}")
56
+ for s in load_servers(p):
57
+ detail = s.url if s.transport == "http" else " ".join(s.command)
58
+ typer.echo(f" {s.name:<20} {s.transport:<6} {detail}")
59
+
60
+
61
+ @app.command()
62
+ def scan(
63
+ config: Annotated[
64
+ list[Path] | None, typer.Argument(help="Config file(s); defaults to auto-discovery")
65
+ ] = None,
66
+ as_json: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False,
67
+ sarif: Annotated[
68
+ bool, typer.Option("--sarif", help="SARIF 2.1.0 output for code scanning")
69
+ ] = False,
70
+ fail_on: Annotated[
71
+ str | None,
72
+ typer.Option("--fail-on", help="Exit 1 on finding >= severity (low|medium|high|critical)"),
73
+ ] = None,
74
+ deep: Annotated[
75
+ bool,
76
+ typer.Option(
77
+ "--deep",
78
+ help="Also run network-dependent checks (e.g. npm registry provenance). "
79
+ "Slower and not fully deterministic offline; off by default.",
80
+ ),
81
+ ] = False,
82
+ output: Annotated[
83
+ Path | None,
84
+ typer.Option("-o", "--output", help="Write output to this file instead of stdout"),
85
+ ] = None,
86
+ ):
87
+ """Scan MCP configurations for security issues (static analysis only)."""
88
+ result = _collect(config, deep=deep)
89
+
90
+ if sarif:
91
+ text = json.dumps(to_sarif(result), indent=2)
92
+ elif as_json:
93
+ text = to_json(result)
94
+ else:
95
+ lines = [f"Scanned {len(result.servers)} server(s) from {len(result.targets)} config(s)."]
96
+ for f in result.findings:
97
+ lines.append(
98
+ f" [{f.severity.upper():<8}] {f.rule_id:<16} {f.server or '-':<16} {f.title}"
99
+ )
100
+ if not result.findings:
101
+ lines.append(" No findings.")
102
+ text = "\n".join(lines)
103
+
104
+ if output:
105
+ output.write_text(text + "\n", encoding="utf-8")
106
+ else:
107
+ typer.echo(text)
108
+
109
+ if fail_on and fail_on in SEVERITY_ORDER:
110
+ if any(SEVERITY_ORDER[f.severity] >= SEVERITY_ORDER[fail_on] for f in result.findings):
111
+ raise typer.Exit(1)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ app()
File without changes
@@ -0,0 +1,167 @@
1
+ """Discover and parse MCP client configurations.
2
+
3
+ Supports the config shapes used by Claude Desktop / Claude Code (.mcp.json, ~/.claude.json),
4
+ Cursor, Windsurf, Gemini CLI, and OpenCode (opencode.json/jsonc, which uses the "mcp" key
5
+ and "local"/"remote" type names).
6
+
7
+ PARSING ONLY — this module never executes a server.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+ import shlex
16
+ from pathlib import Path
17
+
18
+ from ..models import ServerConfig
19
+
20
+ HOME = Path.home()
21
+
22
+
23
+ def _user_config_paths() -> list[Path]:
24
+ paths = [
25
+ HOME / ".claude.json",
26
+ HOME / ".mcp.json",
27
+ HOME / ".cursor" / "mcp.json",
28
+ HOME / ".codeium" / "windsurf" / "mcp_config.json",
29
+ HOME / ".gemini" / "mcp.json",
30
+ HOME / ".config" / "opencode" / "opencode.json",
31
+ ]
32
+ if platform.system() == "Darwin":
33
+ paths.append(HOME / "Library/Application Support/Claude/claude_desktop_config.json")
34
+ elif platform.system() == "Windows":
35
+ appdata = os.environ.get("APPDATA", "")
36
+ if appdata:
37
+ paths.append(Path(appdata) / "Claude" / "claude_desktop_config.json")
38
+ return paths
39
+
40
+
41
+ def discover_configs() -> list[Path]:
42
+ """Project configs first, then user-level configs; only existing files."""
43
+ project_local = [
44
+ Path.cwd() / ".mcp.json",
45
+ Path.cwd() / "opencode.json",
46
+ Path.cwd() / "opencode.jsonc",
47
+ ]
48
+ return [p for p in project_local + _user_config_paths() if p.exists()]
49
+
50
+
51
+ def _strip_jsonc(text: str) -> str:
52
+ """Remove // and /* */ comments from JSONC, preserving string contents."""
53
+ out = []
54
+ i = 0
55
+ n = len(text)
56
+ in_string = False
57
+ escape = False
58
+ while i < n:
59
+ ch = text[i]
60
+ if in_string:
61
+ out.append(ch)
62
+ if escape:
63
+ escape = False
64
+ elif ch == "\\":
65
+ escape = True
66
+ elif ch == '"':
67
+ in_string = False
68
+ i += 1
69
+ continue
70
+ # not in string
71
+ if ch == '"':
72
+ in_string = True
73
+ out.append(ch)
74
+ i += 1
75
+ continue
76
+ if ch == "/" and i + 1 < n:
77
+ nxt = text[i + 1]
78
+ if nxt == "/":
79
+ while i < n and text[i] != "\n":
80
+ i += 1
81
+ continue
82
+ if nxt == "*":
83
+ i += 2
84
+ while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
85
+ i += 1
86
+ i += 2
87
+ continue
88
+ out.append(ch)
89
+ i += 1
90
+ return "".join(out)
91
+
92
+
93
+ def _strip_trailing_commas(text: str) -> str:
94
+ """Remove trailing commas before } or ] (JSONC/JSON5 allows them; json.loads doesn't)."""
95
+ out = []
96
+ i = 0
97
+ n = len(text)
98
+ in_string = False
99
+ escape = False
100
+ while i < n:
101
+ ch = text[i]
102
+ if in_string:
103
+ out.append(ch)
104
+ if escape:
105
+ escape = False
106
+ elif ch == "\\":
107
+ escape = True
108
+ elif ch == '"':
109
+ in_string = False
110
+ i += 1
111
+ continue
112
+ if ch == '"':
113
+ in_string = True
114
+ out.append(ch)
115
+ i += 1
116
+ continue
117
+ if ch == ",":
118
+ j = i + 1
119
+ while j < n and text[j] in " \t\r\n":
120
+ j += 1
121
+ if j < n and text[j] in "}]":
122
+ i += 1
123
+ continue
124
+ out.append(ch)
125
+ i += 1
126
+ return "".join(out)
127
+
128
+
129
+ def _as_command_list(entry: dict) -> list[str]:
130
+ cmd = entry.get("command")
131
+ args = [str(a) for a in entry.get("args", [])]
132
+ if isinstance(cmd, list):
133
+ return [str(x) for x in cmd] + args
134
+ if isinstance(cmd, str):
135
+ parts = shlex.split(cmd)
136
+ parts.extend(args)
137
+ return parts
138
+ return args
139
+
140
+
141
+ def load_servers(path: Path) -> list[ServerConfig]:
142
+ text = path.read_text()
143
+ if path.suffix == ".jsonc":
144
+ text = _strip_jsonc(text)
145
+ text = _strip_trailing_commas(text)
146
+ data = json.loads(text)
147
+
148
+ raw_servers = data.get("mcpServers") or data.get("mcp") or {}
149
+ servers: list[ServerConfig] = []
150
+ for name, entry in raw_servers.items():
151
+ if not isinstance(entry, dict) or entry.get("enabled") is False:
152
+ continue
153
+ raw_type = entry.get("type", "http" if entry.get("url") else "stdio")
154
+ transport = "http" if raw_type in ("http", "remote", "sse", "streamable-http") else "stdio"
155
+ env = entry.get("env") or entry.get("environment") or {}
156
+ servers.append(
157
+ ServerConfig(
158
+ name=name,
159
+ transport=transport,
160
+ command=_as_command_list(entry),
161
+ url=entry.get("url"),
162
+ env={str(k): str(v) for k, v in env.items()},
163
+ source=str(path),
164
+ raw=entry,
165
+ )
166
+ )
167
+ return servers
mcphound/models.py ADDED
@@ -0,0 +1,39 @@
1
+ """Core data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ SEVERITIES = ("low", "medium", "high", "critical")
8
+ SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
9
+
10
+
11
+ class ServerConfig(BaseModel):
12
+ """One MCP server entry parsed from a client config file."""
13
+
14
+ name: str
15
+ transport: str = "stdio" # "stdio" | "http"
16
+ command: list[str] = Field(default_factory=list)
17
+ url: str | None = None
18
+ env: dict[str, str] = Field(default_factory=dict)
19
+ source: str = "" # config file path it came from
20
+ raw: dict = Field(default_factory=dict)
21
+
22
+
23
+ class Finding(BaseModel):
24
+ rule_id: str
25
+ title: str
26
+ severity: str
27
+ confidence: str = "medium"
28
+ owasp: str = "" # LLMxx (OWASP LLM Top 10) or ASTxx (Agentic/MCP Top 10)
29
+ phase: str = "static" # static | dynamic
30
+ server: str | None = None
31
+ location: str = ""
32
+ detail: str = ""
33
+ recommendation: str = ""
34
+
35
+
36
+ class ScanResult(BaseModel):
37
+ targets: list[str] = Field(default_factory=list)
38
+ servers: list[ServerConfig] = Field(default_factory=list)
39
+ findings: list[Finding] = Field(default_factory=list)
mcphound/output.py ADDED
@@ -0,0 +1,68 @@
1
+ """Output formatters: JSON and SARIF 2.1.0 (GitHub code scanning)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import ScanResult
6
+
7
+ _SARIF_LEVEL = {"low": "note", "medium": "warning", "high": "error", "critical": "error"}
8
+ _INFORMATION_URI = "https://github.com/markdoyle4312-hash/mcphound"
9
+ # GitHub's code-scanning ingestion requires security-severity to be a
10
+ # stringified CVSS-like score, not the severity word — see
11
+ # https://docs.github.com/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#security-severity
12
+ _SECURITY_SEVERITY = {"low": "2.5", "medium": "5.5", "high": "7.5", "critical": "9.5"}
13
+
14
+
15
+ def to_json(result: ScanResult) -> str:
16
+ return result.model_dump_json(indent=2)
17
+
18
+
19
+ def _artifact_uri(location: str) -> str:
20
+ # location is "<config path> :: <server name>", built for human display
21
+ # (see rules/engine.py); SARIF's artifactLocation.uri must be a URI, so
22
+ # take just the path and normalize Windows separators.
23
+ path = location.split(" :: ", 1)[0] or "config"
24
+ return path.replace("\\", "/")
25
+
26
+
27
+ def to_sarif(result: ScanResult) -> dict:
28
+ rules: dict[str, dict] = {}
29
+ sarif_results: list[dict] = []
30
+ for f in result.findings:
31
+ rules.setdefault(
32
+ f.rule_id,
33
+ {
34
+ "id": f.rule_id,
35
+ "name": f.title,
36
+ "shortDescription": {"text": f.title},
37
+ "properties": {
38
+ "security-severity": _SECURITY_SEVERITY.get(f.severity, "5.5"),
39
+ "owasp": f.owasp,
40
+ },
41
+ },
42
+ )
43
+ sarif_results.append(
44
+ {
45
+ "ruleId": f.rule_id,
46
+ "level": _SARIF_LEVEL.get(f.severity, "warning"),
47
+ "message": {"text": f"{f.title} ({f.server}) — {f.detail}"},
48
+ "locations": [
49
+ {"physicalLocation": {"artifactLocation": {"uri": _artifact_uri(f.location)}}}
50
+ ],
51
+ }
52
+ )
53
+ return {
54
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
55
+ "version": "2.1.0",
56
+ "runs": [
57
+ {
58
+ "tool": {
59
+ "driver": {
60
+ "name": "mcphound",
61
+ "informationUri": _INFORMATION_URI,
62
+ "rules": list(rules.values()),
63
+ }
64
+ },
65
+ "results": sarif_results,
66
+ }
67
+ ],
68
+ }
@@ -0,0 +1,17 @@
1
+ id: MCP-STATIC-001
2
+ title: Hardcoded secret in MCP server environment
3
+ owasp: LLM02 # Sensitive Information Disclosure
4
+ phase: static
5
+ severity: high
6
+ confidence: high
7
+ description: >
8
+ A literal credential/token appears in an MCP server env block. Committed
9
+ credentials leak through git, CI logs, and shared machine configs. Use
10
+ environment variable expansion (${VAR}) instead.
11
+ detect:
12
+ target: env
13
+ pattern: '(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*["'']?(sk-[a-z0-9]{12,}|xox[baprs]-[a-z0-9-]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|[A-Za-z0-9]{32,})'
14
+ allow_if: '${' # ${ENV_VAR} expansion is the allowed pattern
15
+ recommendation: Replace with "${VAR}" expansion; rotate the leaked credential; add gitleaks.
16
+ references:
17
+ - https://www.owasp.org/index.php/OWASP_Top_10_for_LLM_Applications
@@ -0,0 +1,16 @@
1
+ id: MCP-STATIC-002
2
+ title: Remote code download-and-execute in launch command
3
+ owasp: AST04 # Supply chain: malicious/unsigned tooling
4
+ phase: static
5
+ severity: critical
6
+ confidence: high
7
+ description: >
8
+ The server launch command pipes a remote download straight into a shell
9
+ (curl|sh / wget|sh), executing unversioned remote code at every startup —
10
+ the rug-pull / supply-chain kill chain in one line.
11
+ detect:
12
+ target: command
13
+ pattern: '(curl|wget)\s+[^|]+\|\s*(sh|bash|zsh)'
14
+ recommendation: Install a pinned package from a registry; vendor the script; verify signatures.
15
+ references:
16
+ - https://labs.cloudsecurityalliance.org/research/csa-research-note-mcp-tool-poisoning-auto-execution-20260701/
@@ -0,0 +1,17 @@
1
+ id: MCP-STATIC-003
2
+ title: Over-broad host/filesystem permissions in launch command
3
+ owasp: LLM08 # Excessive Agency
4
+ phase: static
5
+ severity: high
6
+ confidence: medium
7
+ description: >
8
+ The server launch command requests host-level or filesystem-root access
9
+ (privileged containers, host networking, root volume mounts, disabled
10
+ browser sandboxing, or a bare "/" path argument) — violating least-privilege
11
+ and giving a compromised server full host reach instead of a scoped one.
12
+ detect:
13
+ target: command
14
+ pattern: '(?i)(--privileged\b|--network[=\s]+host\b|--cap-add[=\s]+all\b|-v\s+/:/|--no-sandbox\b|(?:^|\s)/(?:\s|$))'
15
+ recommendation: Scope volume mounts/paths to the minimum directory needed, drop --privileged/--network=host/--cap-add=ALL, and keep browser sandboxing enabled.
16
+ references:
17
+ - https://labs.cloudsecurityalliance.org/agentic/agentic-mcp-security-best-practices-v1/
@@ -0,0 +1,17 @@
1
+ id: MCP-STATIC-004
2
+ title: Unpinned or @latest package version in launch command
3
+ owasp: AST04 # Supply chain: malicious/unsigned tooling
4
+ phase: static
5
+ severity: medium
6
+ confidence: medium
7
+ description: >
8
+ An npx/uvx launch command references a package with no version pin (or the
9
+ mutable "@latest" tag). The exact code that runs can change between
10
+ invocations without review — the same rug-pull risk class as unsigned
11
+ install scripts, just via registry mutation instead of a script.
12
+ detect:
13
+ target: command
14
+ pattern: '(?i)\b(?:npx|uvx)\s+(?:-y\s+)?(?P<pkg>@?[\w][\w./-]*)(?!\S*@\d)'
15
+ recommendation: Pin an exact version, e.g. "npx -y pkg@1.2.3"; bump deliberately and note the bump in the commit message.
16
+ references:
17
+ - https://labs.cloudsecurityalliance.org/research/csa-research-note-mcp-tool-poisoning-auto-execution-20260701/
@@ -0,0 +1,18 @@
1
+ id: MCP-STATIC-005
2
+ title: Tool-description injection markers in server config
3
+ owasp: LLM01 # Prompt Injection
4
+ phase: static
5
+ severity: critical
6
+ confidence: medium
7
+ description: >
8
+ The server's config entry contains hidden HTML/XML comment blocks, zero-width
9
+ Unicode characters, or exfiltration-imperative phrasing ("ignore previous
10
+ instructions", "send ... to", "do not tell the user"). These are the
11
+ documented tool-poisoning markers used to plant instructions a human
12
+ reviewer won't see but an LLM reading the tool/description text will.
13
+ detect:
14
+ target: raw
15
+ pattern: "(?i)([\\u200b\\u200c\\u200d\\ufeff\\u2060]|<!--[\\s\\S]*?-->|ignore\\s+(?:all\\s+)?previous\\s+instructions|do\\s+not\\s+tell\\s+the\\s+user|without\\s+(?:informing|telling)\\s+the\\s+user|send\\s+(?:this|it|the\\s+\\w+)\\s+to\\s+)"
16
+ recommendation: Remove hidden/zero-width content and imperative phrasing from server metadata; review the server's actual tool descriptions via the MCP Inspector before trusting it.
17
+ references:
18
+ - https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks
@@ -0,0 +1,19 @@
1
+ id: MCP-STATIC-006
2
+ title: Typosquat of a known MCP server package name
3
+ owasp: AST04 # Supply chain: malicious/unsigned tooling
4
+ phase: static
5
+ severity: high
6
+ confidence: medium
7
+ description: >
8
+ The npx/uvx package name launched by this server is one or two edits away
9
+ from a well-known legitimate MCP server package, but not an exact match —
10
+ the classic typosquat pattern (extra/missing/transposed characters) used to
11
+ trick installers into running a lookalike package instead of the real one.
12
+ detect:
13
+ type: typosquat
14
+ target: command
15
+ reference_list: known_servers.yaml
16
+ max_distance: 2
17
+ recommendation: Double-check the exact package name against the official registry/npm page before installing; pin the verified name and version.
18
+ references:
19
+ - https://appsentinels.ai/blog/mcp-supply-chain-security-how-malicious-mcp-servers-are-infiltrating-enterprise-ai-environments/
@@ -0,0 +1,19 @@
1
+ id: MCP-STATIC-007
2
+ title: npm package has no discoverable source repository
3
+ owasp: AST04 # Supply chain: malicious/unsigned tooling
4
+ phase: static
5
+ network: true # queries registry.npmjs.org — opt-in via `mcphound scan --deep`
6
+ severity: medium
7
+ confidence: low
8
+ description: >
9
+ The npx-launched package's npm registry metadata has no "repository" field —
10
+ there's no public source to audit, review, or diff against a future version.
11
+ ~15% of registry MCP servers ship with no source repo (Nimblebrain, 2026).
12
+ A missing repo isn't proof of malice (small/private packages omit it too),
13
+ so this is a low-confidence signal, not a hard block.
14
+ detect:
15
+ type: npm_provenance
16
+ target: command
17
+ recommendation: Prefer packages with a public, reviewable source repository; if you maintain this package, add a "repository" field to package.json.
18
+ references:
19
+ - https://nimblebrain.ai/mcp/mcp-security/state-of-mcp-security/
File without changes
@@ -0,0 +1,13 @@
1
+ # Seed list of well-known, legitimate MCP server package names, used only by
2
+ # MCP-STATIC-006's typosquat check. This is NOT the official registry — it's a
3
+ # small bundled list so the check works fully offline and deterministically.
4
+ # The Phase 2 registry poller (ROADMAP.md) will eventually supersede this with
5
+ # live, comprehensive registry data.
6
+ - "@modelcontextprotocol/server-filesystem"
7
+ - "@modelcontextprotocol/server-postgres"
8
+ - "@modelcontextprotocol/server-everything"
9
+ - "@modelcontextprotocol/server-sqlite"
10
+ - "@upstash/context7-mcp"
11
+ - "@playwright/mcp"
12
+ - "@google/mcp-server"
13
+ - "mcp-server-sqlite"
@@ -0,0 +1,176 @@
1
+ """Evaluate detection rules against parsed server configurations.
2
+
3
+ Static rules only at this stage. A rule's `detect` block is one of two shapes:
4
+
5
+ Regex (default, `type` omitted):
6
+ target: command | url | env | raw (which text to inspect)
7
+ pattern: <regex>
8
+ allow_if: <substring> (skip match when present on the matched line,
9
+ e.g. "${" to allow env-var expansion)
10
+
11
+ Typosquat (`type: typosquat`) — needs edit-distance, not a regex, so it has
12
+ dedicated engine support rather than being pure YAML data:
13
+ type: typosquat
14
+ target: command (only "command" is supported today)
15
+ reference_list: <file under rules/data/> (YAML list of known-legitimate package names)
16
+ max_distance: <int> (flag names within this Levenshtein distance
17
+ of a reference name, but not an exact match)
18
+
19
+ npm provenance (`type: npm_provenance`) — queries the public npm registry, so it
20
+ is NOT part of the default free scan (GOVERNANCE.md: network-dependent checks
21
+ must be marked and separable). Any rule using it MUST also set `network: true`;
22
+ `cli.py` filters such rules out unless `--deep` is passed:
23
+ type: npm_provenance
24
+ target: command (only npx-launched packages are checked)
25
+
26
+ One finding per rule per server (first match wins).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import functools
32
+ import json
33
+ import re
34
+ from pathlib import Path
35
+
36
+ import httpx
37
+ import yaml
38
+ from rapidfuzz.distance import Levenshtein
39
+
40
+ from ..models import Finding, ServerConfig
41
+
42
+ _DATA_DIR = Path(__file__).parent / "data"
43
+ _NPM_TIMEOUT = 5.0
44
+
45
+
46
+ def _target_text(server: ServerConfig, target: str) -> str:
47
+ if target == "command":
48
+ return " ".join(server.command)
49
+ if target == "url":
50
+ return server.url or ""
51
+ if target == "env":
52
+ return "\n".join(f"{k}={v}" for k, v in server.env.items())
53
+ return json.dumps(server.raw, sort_keys=True, ensure_ascii=False)
54
+
55
+
56
+ def _make_finding(rule: dict, server: ServerConfig, detail: str) -> Finding:
57
+ return Finding(
58
+ rule_id=rule["id"],
59
+ title=rule.get("title", rule["id"]),
60
+ severity=rule.get("severity", "medium"),
61
+ confidence=rule.get("confidence", "medium"),
62
+ owasp=rule.get("owasp", ""),
63
+ phase="static",
64
+ server=server.name,
65
+ location=f"{server.source} :: {server.name}",
66
+ detail=detail[:160],
67
+ recommendation=rule.get("recommendation", ""),
68
+ )
69
+
70
+
71
+ def _evaluate_regex(server: ServerConfig, rule: dict, detect: dict) -> list[Finding]:
72
+ pattern = detect.get("pattern")
73
+ if not pattern:
74
+ return []
75
+ text = _target_text(server, detect.get("target", "command"))
76
+ allow_if = detect.get("allow_if")
77
+ findings = []
78
+ for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
79
+ window = text[max(0, match.start() - 60) : match.end() + 60]
80
+ if allow_if and allow_if in window:
81
+ continue
82
+ findings.append(_make_finding(rule, server, match.group(0)))
83
+ break
84
+ return findings
85
+
86
+
87
+ @functools.lru_cache
88
+ def _load_reference_list(filename: str) -> tuple[str, ...]:
89
+ data = yaml.safe_load((_DATA_DIR / filename).read_text(encoding="utf-8"))
90
+ return tuple(data) if isinstance(data, list) else ()
91
+
92
+
93
+ def _package_name(spec: str) -> str:
94
+ """Strip a trailing "@version" (or "@latest") from an npm-style package spec,
95
+ preserving a scoped package's own leading "@scope/" segment."""
96
+ if spec.startswith("@"):
97
+ slash = spec.find("/")
98
+ if slash == -1:
99
+ return spec
100
+ at = spec.find("@", slash)
101
+ else:
102
+ at = spec.find("@")
103
+ return spec[:at] if at != -1 else spec
104
+
105
+
106
+ def _extract_command_package(server: ServerConfig) -> str | None:
107
+ tokens = server.command
108
+ for i, tok in enumerate(tokens):
109
+ if tok in ("npx", "uvx"):
110
+ for cand in tokens[i + 1 :]:
111
+ if cand.startswith("-"):
112
+ continue
113
+ return _package_name(cand)
114
+ return None
115
+
116
+
117
+ def _evaluate_typosquat(server: ServerConfig, rule: dict, detect: dict) -> list[Finding]:
118
+ pkg = _extract_command_package(server)
119
+ if not pkg:
120
+ return []
121
+ reference = _load_reference_list(detect.get("reference_list", ""))
122
+ if not reference or pkg in reference:
123
+ return []
124
+ max_distance = int(detect.get("max_distance", 2))
125
+ best = min(reference, key=lambda ref: Levenshtein.distance(pkg, ref))
126
+ distance = Levenshtein.distance(pkg, best)
127
+ if distance == 0 or distance > max_distance:
128
+ return []
129
+ detail = f'"{pkg}" is {distance} edit(s) from known package "{best}"'
130
+ return [_make_finding(rule, server, detail)]
131
+
132
+
133
+ def _fetch_npm_metadata(pkg: str) -> dict | None:
134
+ """Isolated so tests can monkeypatch it instead of hitting the real registry."""
135
+ try:
136
+ resp = httpx.get(f"https://registry.npmjs.org/{pkg}", timeout=_NPM_TIMEOUT)
137
+ resp.raise_for_status()
138
+ return resp.json()
139
+ except httpx.HTTPError:
140
+ return None
141
+
142
+
143
+ def _evaluate_npm_provenance(server: ServerConfig, rule: dict, detect: dict) -> list[Finding]:
144
+ if "npx" not in server.command:
145
+ return []
146
+ pkg = _extract_command_package(server)
147
+ if not pkg:
148
+ return []
149
+ meta = _fetch_npm_metadata(pkg)
150
+ if meta is None:
151
+ # Network error or unknown package: no data to judge on, so no finding —
152
+ # never let a provenance check fail closed against an offline/rate-limited registry.
153
+ return []
154
+ latest = meta.get("dist-tags", {}).get("latest")
155
+ version_info = meta.get("versions", {}).get(latest, {}) if latest else {}
156
+ repo = version_info.get("repository") or meta.get("repository")
157
+ if repo:
158
+ return []
159
+ detail = f'npm package "{pkg}" has no repository field in its registry metadata'
160
+ return [_make_finding(rule, server, detail)]
161
+
162
+
163
+ def evaluate(server: ServerConfig, rules: list[dict]) -> list[Finding]:
164
+ findings: list[Finding] = []
165
+ for rule in rules:
166
+ if rule.get("phase", "static") != "static":
167
+ continue
168
+ detect = rule.get("detect") or {}
169
+ detect_type = detect.get("type")
170
+ if detect_type == "typosquat":
171
+ findings.extend(_evaluate_typosquat(server, rule, detect))
172
+ elif detect_type == "npm_provenance":
173
+ findings.extend(_evaluate_npm_provenance(server, rule, detect))
174
+ else:
175
+ findings.extend(_evaluate_regex(server, rule, detect))
176
+ return findings
@@ -0,0 +1,19 @@
1
+ """Load YAML detection rules from src/mcphound/rules/*.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ RULES_DIR = Path(__file__).parent
10
+
11
+
12
+ def load_rules(rules_dir: Path | None = None) -> list[dict]:
13
+ rules_dir = rules_dir or RULES_DIR
14
+ rules: list[dict] = []
15
+ for path in sorted(rules_dir.glob("*.yaml")):
16
+ data = yaml.safe_load(path.read_text())
17
+ if isinstance(data, dict) and data.get("id"):
18
+ rules.append(data)
19
+ return rules
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcphound
3
+ Version: 0.1.0
4
+ Summary: Independent security scanner and reputation layer for MCP servers and agent skills
5
+ Project-URL: Homepage, https://github.com/markdoyle4312-hash/mcphound
6
+ Project-URL: Repository, https://github.com/markdoyle4312-hash/mcphound
7
+ Project-URL: Issues, https://github.com/markdoyle4312-hash/mcphound/issues
8
+ Project-URL: Changelog, https://github.com/markdoyle4312-hash/mcphound/blob/main/CHANGELOG.md
9
+ Author-email: marky-dooda08 <mark.doyle4312@gmail.com>
10
+ License: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,mcp,sarif,security,supply-chain
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Security
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pydantic>=2.7
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: rapidfuzz>=3.9
26
+ Requires-Dist: rich>=13.0
27
+ Requires-Dist: typer>=0.12
28
+ Provides-Extra: dev
29
+ Requires-Dist: pre-commit>=3.8; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # mcpvet
35
+
36
+ Independent security scanner and reputation layer for MCP servers and agent skills.
37
+
38
+ > Status: **pre-launch.** v0.1 = static CLI scanning. Reputation site/API and the GitHub Action follow per [ROADMAP.md](ROADMAP.md).
39
+
40
+ mcpvet discovers the MCP servers configured in your AI coding clients (Claude Code/Desktop, Cursor, Windsurf, Gemini CLI, OpenCode) and checks them for supply-chain risks: hardcoded secrets, download-and-execute launch commands, over-broad permissions, pinned-version drift, and (in later versions) tool-description poisoning, typosquats, and runtime rug-pulls. Findings map to the OWASP Top 10 for LLM and Agentic applications and can be exported as SARIF into GitHub code scanning.
41
+
42
+ ## Why another scanner?
43
+
44
+ Local scanning is covered by [mcp-scan](https://github.com/invariantlabs-ai/mcp-scan) / Snyk Agent Scan. mcpvet's job is the layers around it:
45
+
46
+ 1. A **public reputation database** — continuously scanned public registry, per-server risk pages and change history.
47
+ 2. **Org-level enforcement** — `mcp-policy.yaml` + GitHub Action that blocks risky MCP/skill changes in PRs.
48
+ 3. **Compliance reporting** — findings mapped to OWASP, EU AI Act, and (later) Australian ISM / Essential Eight / DISP controls.
49
+
50
+ ## Quickstart
51
+
52
+ ```bash
53
+ # published on PyPI as "mcphound" (mcpvet and mcp-vet were both already
54
+ # taken/rejected) — mcphound is also installed as the "mcpvet" command,
55
+ # kept as a back-compat alias
56
+ uvx mcphound inspect # inspect what you have (never executes a server)
57
+ uvx mcphound scan # scan auto-discovered configs
58
+
59
+ # or install once:
60
+ # pip install mcphound / uv tool install mcphound
61
+
62
+ # CI: fail on high/critical findings, emit SARIF
63
+ mcphound scan .mcp.json --fail-on high --sarif -o mcphound.sarif
64
+
65
+ # opt-in: also run network-dependent checks (npm registry provenance) — slower,
66
+ # not fully deterministic offline, so it's off unless you ask for it
67
+ mcphound scan --deep
68
+ ```
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ uv sync --extra dev
74
+ uv run pytest -q # tests
75
+ uv run ruff check . # lint
76
+ make scan-self # scan this repo's own agent configs (dogfood)
77
+ ```
78
+
79
+ ## Safety
80
+
81
+ mcpvet static scanning **never executes** MCP servers. Dynamic analysis (post-v1) runs only inside the disposable, network-isolated sandbox in `sandbox/`. Every malicious test fixture carries the marker `MCPVET-FIXTURE-CANARY` and must never be referenced from agent configurations.
82
+
83
+ ## Authoring a detection rule
84
+
85
+ Every rule ships with four artifacts: YAML rule, malicious fixture, benign fixture, pytest. See `.claude/skills/rule-authoring/SKILL.md` (or the two worked examples under `src/mcpvet/rules/` and `tests/`).
86
+
87
+ ## License
88
+
89
+ Apache-2.0
@@ -0,0 +1,22 @@
1
+ mcphound/__init__.py,sha256=KZ4wU1jcPvuDtSibm8YmjmQ3V6rzC1DGNOy7ioFwyYE,96
2
+ mcphound/cli.py,sha256=IrrRQFbifXluxxy5-k4StK31YnqYGVKQWeP5N4OBDUE,3769
3
+ mcphound/models.py,sha256=R0q9y5V-1SKhZL3_C4LQ0zpVXlmOZrQLeUhshL7itdE,1191
4
+ mcphound/output.py,sha256=yeFSyWp3n550euEzjND0g39G1nFX2xGum98VciTXzQ4,2539
5
+ mcphound/discovery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ mcphound/discovery/clients.py,sha256=tDJBpjJwJ8Ars-wOcwbMNb86NxaIf1Oo0f8SRfslZTQ,5071
7
+ mcphound/rules/MCP-STATIC-001.yaml,sha256=wYufYk-PUkV0bWbshF5tpGoc3BSwd9KEOKxY8n_dwOA,829
8
+ mcphound/rules/MCP-STATIC-002.yaml,sha256=KimwV2d0m80QbZrME8E24vBH56YsSU2iVXHFZWrsvrY,728
9
+ mcphound/rules/MCP-STATIC-003.yaml,sha256=j1Prp_raE7ezwhMMruNrvTkjpTanw3MStOck5tq4X3A,914
10
+ mcphound/rules/MCP-STATIC-004.yaml,sha256=cDV9e3kH0KIjmCk7IFJVjEacAK1iyjoIY3B-TKFZDks,866
11
+ mcphound/rules/MCP-STATIC-005.yaml,sha256=aLzLilO3haLj_RKVfwbOmXW6nRP2O4Q4cyQ6S8dwj-k,1118
12
+ mcphound/rules/MCP-STATIC-006.yaml,sha256=jzB805GKxzXEUH9N8DTTZw2ucXhgnD-ttnBsasM-xtE,913
13
+ mcphound/rules/MCP-STATIC-007.yaml,sha256=SwbgVr12QTV38hvw1cVLUi8vsVzJmkUEo5x1GiFU2hA,959
14
+ mcphound/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ mcphound/rules/engine.py,sha256=Xao2xNa5VXF3l4f69ginydHIimEz5XsHDx87gbmu2Ac,6762
16
+ mcphound/rules/loader.py,sha256=3VU7jsA-SLsV_9pRbTAnGaTr7tVVmm71hsxExtZpJFA,522
17
+ mcphound/rules/data/known_servers.yaml,sha256=VmVGFYjbvJCFFnEYh5NssI0dkOK8p1HP0oeqL52ZAX8,625
18
+ mcphound-0.1.0.dist-info/METADATA,sha256=sk0KTPH98rCsxAEUexjbOMdAphfAFkcBdPWg5JCYPxY,4030
19
+ mcphound-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
20
+ mcphound-0.1.0.dist-info/entry_points.txt,sha256=ERL5p0-evBvIbcl5REIO0WEx_7VnoG1xyfuVZFSOw0k,72
21
+ mcphound-0.1.0.dist-info/licenses/LICENSE,sha256=4r_lcf9CteSJthIVtn90N2tG8dYr-Xczy9XfhcDUsqs,11449
22
+ mcphound-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mcphound = mcphound.cli:app
3
+ mcpvet = mcphound.cli:app
@@ -0,0 +1,205 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 mcpvet contributors
18
+
19
+ Licensed under the Apache License, Version 2.0 (the "License");
20
+ you may not use this file except in compliance with the License.
21
+ You may obtain a copy of the License at
22
+
23
+ http://www.apache.org/licenses/LICENSE-2.0
24
+
25
+ Unless required by applicable law or agreed to in writing, software
26
+ distributed under the License is distributed on an "AS IS" BASIS,
27
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28
+ See the License for the specific language governing permissions and
29
+ limitations under the License.
30
+
31
+ APPENDIX: Full Apache License 2.0 terms are available at
32
+ http://www.apache.org/licenses/LICENSE-2.0.txt and reproduced below.
33
+
34
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
35
+
36
+ 1. Definitions.
37
+
38
+ "License" shall mean the terms and conditions for use, reproduction,
39
+ and distribution as defined by Sections 1 through 9 of this document.
40
+
41
+ "Licensor" shall mean the copyright owner or entity authorized by
42
+ the copyright owner that is granting the License.
43
+
44
+ "Legal Entity" shall mean the union of the acting entity and all
45
+ other entities that control, are controlled by, or are under common
46
+ control with that entity. For the purposes of this definition,
47
+ "control" means (i) the power, direct or indirect, to cause the
48
+ direction or management of such entity, whether by contract or
49
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
50
+ outstanding shares, or (iii) beneficial ownership of such entity.
51
+
52
+ "You" (or "Your") shall mean an individual or Legal Entity
53
+ exercising permissions granted by this License.
54
+
55
+ "Source" form shall mean the preferred form for making modifications,
56
+ including but not limited to software source code, documentation
57
+ source, and configuration files.
58
+
59
+ "Object" form shall mean any form resulting from mechanical
60
+ transformation or translation of a Source form, including but
61
+ not limited to compiled object code, generated documentation,
62
+ and conversions to other media types.
63
+
64
+ "Work" shall mean the work of authorship, whether in Source or
65
+ Object form, made available under the License, as indicated by a
66
+ copyright notice that is included in or attached to the work
67
+ (an example is provided in the Appendix below).
68
+
69
+ "Derivative Works" shall mean any work, whether in Source or Object
70
+ form, that is based on (or derived from) the Work and for which the
71
+ editorial revisions, annotations, elaborations, or other modifications
72
+ represent, as a whole, an original work of authorship. For the purposes
73
+ of this License, Derivative Works shall not include works that remain
74
+ separable from, or merely link (or bind by name) to the interfaces of,
75
+ the Work and Derivative Works thereof.
76
+
77
+ "Contribution" shall mean any work of authorship, including
78
+ the original version of the Work and any modifications or additions
79
+ to that Work or Derivative Works thereof, that is intentionally
80
+ submitted to Licensor for inclusion in the Work by the copyright owner
81
+ or by an individual or Legal Entity authorized to submit on behalf of
82
+ the copyright owner. For the purposes of this definition, "submitted"
83
+ means any form of electronic, verbal, or written communication sent
84
+ to the Licensor or its representatives, including but not limited to
85
+ communication on electronic mailing lists, source code control systems,
86
+ and issue tracking systems that are managed by, or on behalf of, the
87
+ Licensor for the purpose of discussing and improving the Work, but
88
+ excluding communication that is conspicuously marked or otherwise
89
+ designated in writing by the copyright owner as "Not a Contribution."
90
+
91
+ "Contributor" shall mean Licensor and any individual or Legal Entity
92
+ on behalf of whom a Contribution has been received by Licensor and
93
+ subsequently incorporated within the Work.
94
+
95
+ 2. Grant of Copyright License. Subject to the terms and conditions of
96
+ this License, each Contributor hereby grants to You a perpetual,
97
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
98
+ copyright license to reproduce, prepare Derivative Works of,
99
+ publicly display, publicly perform, sublicense, and distribute the
100
+ Work and such Derivative Works in Source or Object form.
101
+
102
+ 3. Grant of Patent License. Subject to the terms and conditions of
103
+ this License, each Contributor hereby grants to You a perpetual,
104
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
105
+ (except as stated in this section) patent license to make, have made,
106
+ use, offer to sell, sell, import, and otherwise transfer the Work,
107
+ where such license applies only to those patent claims licensable
108
+ by such Contributor that are necessarily infringed by their
109
+ Contribution(s) alone or by combination of their Contribution(s)
110
+ with the Work to which such Contribution(s) was submitted. If You
111
+ institute patent litigation against any entity (including a
112
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
113
+ or a Contribution incorporated within the Work constitutes direct
114
+ or contributory patent infringement, then any patent licenses
115
+ granted to You under this License for that Work shall terminate
116
+ as of the date such litigation is filed.
117
+
118
+ 4. Redistribution. You may reproduce and distribute copies of the
119
+ Work or Derivative Works thereof in any medium, with or without
120
+ modifications, and in Source or Object form, provided that You
121
+ meet the following conditions:
122
+
123
+ (a) You must give any other recipients of the Work or
124
+ Derivative Works a copy of this License; and
125
+
126
+ (b) You must cause any modified files to carry prominent notices
127
+ stating that You changed the files; and
128
+
129
+ (c) You must retain, in the Source form of any Derivative Works
130
+ that You distribute, all copyright, patent, trademark, and
131
+ attribution notices from the Source form of the Work,
132
+ excluding those notices that do not pertain to any part of
133
+ the Derivative Works; and
134
+
135
+ (d) If the Work includes a "NOTICE" text file as part of its
136
+ distribution, then any Derivative Works that You distribute must
137
+ include a readable copy of the attribution notices contained
138
+ within such NOTICE file, excluding those notices that do not
139
+ pertain to any part of the Derivative Works, in at least one
140
+ of the following places: within a NOTICE text file distributed
141
+ as part of the Derivative Works; within the Source form or
142
+ documentation, if provided along with the Derivative Works; or,
143
+ within a display generated by the Derivative Works, if and
144
+ wherever such third-party notices normally appear. The contents
145
+ of the NOTICE file are for informational purposes only and
146
+ do not modify the License. You may add Your own attribution
147
+ notices within Derivative Works that You distribute, alongside
148
+ or as an addendum to the NOTICE text from the Work, provided
149
+ that such additional attribution notices cannot be construed
150
+ as modifying the License.
151
+
152
+ You may add Your own copyright statement to Your modifications and
153
+ may provide additional or different license terms and conditions
154
+ for use, reproduction, or distribution of Your modifications, or
155
+ for any such Derivative Works as a whole, provided Your use,
156
+ reproduction, and distribution of the Work otherwise complies with
157
+ the conditions stated in this License.
158
+
159
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
160
+ any Contribution intentionally submitted for inclusion in the Work
161
+ by You to the Licensor shall be under the terms and conditions of
162
+ this License, without any additional terms or conditions.
163
+ Notwithstanding the above, nothing herein shall supersede or modify
164
+ the terms of any separate license agreement you may have executed
165
+ with Licensor regarding such Contributions.
166
+
167
+ 6. Trademarks. This License does not grant permission to use the trade
168
+ names, trademarks, service marks, or product names of the Licensor,
169
+ except as required for reasonable and customary use in describing the
170
+ origin of the Work and reproducing the content of the NOTICE file.
171
+
172
+ 7. Disclaimer of Warranty. Unless required by applicable law or
173
+ agreed to in writing, Licensor provides the Work (and each
174
+ Contributor provides its Contributions) on an "AS IS" BASIS,
175
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
176
+ implied, including, without limitation, any warranties or conditions
177
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
178
+ PARTICULAR PURPOSE. You are solely responsible for determining the
179
+ appropriateness of using or redistributing the Work and assume any
180
+ risks associated with Your exercise of permissions under this License.
181
+
182
+ 8. Limitation of Liability. In no event and under no legal theory,
183
+ whether in tort (including negligence), contract, or otherwise,
184
+ unless required by applicable law (such as deliberate and grossly
185
+ negligent acts) or agreed to in writing, shall any Contributor be
186
+ liable to You for damages, including any direct, indirect, special,
187
+ incidental, or consequential damages of any character arising as a
188
+ result of this License or out of the use or inability to use the
189
+ Work (including but not limited to damages for loss of goodwill,
190
+ work stoppage, computer failure or malfunction, or any and all
191
+ other commercial damages or losses), even if such Contributor
192
+ has been advised of the possibility of such damages.
193
+
194
+ 9. Accepting Warranty or Additional Liability. While redistributing
195
+ the Work or Derivative Works thereof, You may choose to offer,
196
+ and charge a fee for, acceptance of support, warranty, indemnity,
197
+ or other liability obligations and/or rights consistent with this
198
+ License. However, in accepting such obligations, You may act only
199
+ on Your own behalf and on Your sole responsibility, not on behalf
200
+ of any other Contributor, and only if You agree to indemnify,
201
+ defend, and hold each Contributor harmless for any liability
202
+ incurred by, or claims asserted against, such Contributor by reason
203
+ of your accepting any such warranty or additional liability.
204
+
205
+ END OF TERMS AND CONDITIONS