mcpprobe-scanner 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.
@@ -0,0 +1,228 @@
1
+ """Plugin framework for checks.
2
+
3
+ A check is a class with an ``id``, ``title``, default ``severity``, a
4
+ ``standards`` mapping, a ``mode`` (passive or active) and an async
5
+ ``run(ctx) -> list[Finding]``. The orchestrator decides whether a check may run
6
+ (active checks require the authorisation gate) and records a
7
+ :class:`~mcpprobe.models.CheckResult` for every check, including skipped ones,
8
+ so reports show coverage honestly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import abc
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from mcpprobe.models import (
19
+ CheckMode,
20
+ Confidence,
21
+ EvidenceRecord,
22
+ Finding,
23
+ Fingerprint,
24
+ Inventory,
25
+ Severity,
26
+ Standards,
27
+ )
28
+ from mcpprobe.transport import EvidenceLog, HttpTransport, Transport
29
+
30
+ if TYPE_CHECKING:
31
+ from mcpprobe.authz import AuthorisationRecord
32
+
33
+ #: Signature of the optional LLM-judge hook. It receives the text under review
34
+ #: and a short task description and returns ``None`` (abstain) or a verdict.
35
+ JudgeFn = Callable[[str, str], "JudgeVerdict | None"]
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class JudgeVerdict:
40
+ """Verdict from an optional second-opinion judge (no network call is made by mcpprobe)."""
41
+
42
+ suspicious: bool
43
+ rationale: str = ""
44
+ confidence: Confidence = Confidence.LOW
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class ScanOptions:
49
+ """User-controlled knobs that checks may consult."""
50
+
51
+ mode: CheckMode = CheckMode.PASSIVE
52
+ baseline_path: str | None = None
53
+ write_baseline: bool = True
54
+ judge: JudgeFn | None = None
55
+ excluded_tools: set[str] = field(default_factory=set)
56
+ #: AUTH-03 fetches the authorization server's metadata, which may live on a
57
+ #: third-party host. Set False to keep all traffic on the target host.
58
+ follow_authorization_server: bool = True
59
+ #: Environment passed to a stdio server (scanned by META-05 for secrets).
60
+ stdio_env: dict[str, str] = field(default_factory=dict)
61
+ stdio_command: list[str] = field(default_factory=list)
62
+ timeout: float = 15.0
63
+ #: DNS TXT resolver used for opt-out checks on third-party hosts (injected in tests).
64
+ txt_lookup: Callable[[str], list[str]] | None = None
65
+
66
+
67
+ class ScanContext:
68
+ """Everything a check needs: the negotiated transport, the inventory and options."""
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ target: str,
74
+ transport: Transport,
75
+ fingerprint: Fingerprint,
76
+ inventory: Inventory,
77
+ evidence: EvidenceLog,
78
+ options: ScanOptions | None = None,
79
+ authorisation: AuthorisationRecord | None = None,
80
+ ) -> None:
81
+ self.target = target
82
+ self.transport = transport
83
+ self.fingerprint = fingerprint
84
+ self.inventory = inventory
85
+ self.evidence = evidence
86
+ self.options = options or ScanOptions()
87
+ self.authorisation = authorisation
88
+ self.notes: list[str] = []
89
+ #: Scratch space for checks to share derived data (e.g. META-03 classes).
90
+ self.shared: dict[str, Any] = {}
91
+
92
+ @property
93
+ def http(self) -> HttpTransport | None:
94
+ return self.transport if isinstance(self.transport, HttpTransport) else None
95
+
96
+ @property
97
+ def is_http(self) -> bool:
98
+ return self.http is not None
99
+
100
+ @property
101
+ def active_allowed(self) -> bool:
102
+ """Active checks may run only when the authorisation gate produced a verified record."""
103
+ return (
104
+ self.options.mode == CheckMode.ACTIVE
105
+ and self.authorisation is not None
106
+ and self.authorisation.verified
107
+ )
108
+
109
+ def tool_names(self) -> list[str]:
110
+ return [str(t.get("name", "")) for t in self.inventory.tools]
111
+
112
+
113
+ class Check(abc.ABC):
114
+ """Base class for all checks. Subclasses set the class attributes and implement ``run``."""
115
+
116
+ id: str = "BASE-00"
117
+ title: str = ""
118
+ severity: Severity = Severity.MEDIUM
119
+ standards: Standards = Standards()
120
+ mode: CheckMode = CheckMode.PASSIVE
121
+ #: Which transports the check makes sense for.
122
+ applies_to: frozenset[str] = frozenset({"http", "stdio"})
123
+ #: One-paragraph description for CHECKS.md and SARIF rule help.
124
+ description: str = ""
125
+ #: What the evidence consists of (used in docs).
126
+ evidence_type: str = ""
127
+ #: Known false-positive conditions (used in docs and attached to findings).
128
+ false_positive_notes: str = ""
129
+ #: Default remediation text.
130
+ fix: str = ""
131
+
132
+ @abc.abstractmethod
133
+ async def run(self, ctx: ScanContext) -> list[Finding]:
134
+ """Execute the check and return zero or more findings."""
135
+
136
+ def finding(
137
+ self,
138
+ *,
139
+ description: str,
140
+ severity: Severity | None = None,
141
+ confidence: Confidence = Confidence.HIGH,
142
+ evidence: list[EvidenceRecord] | None = None,
143
+ location: str | None = None,
144
+ fix: str | None = None,
145
+ details: dict[str, Any] | None = None,
146
+ title: str | None = None,
147
+ excerpt: str | None = None,
148
+ ) -> Finding:
149
+ """Build a :class:`Finding` pre-filled from the check's metadata.
150
+
151
+ ``excerpt`` overrides the evidence excerpt that is otherwise derived from
152
+ the first evidence record.
153
+ """
154
+ records = [e for e in (evidence or []) if e is not None]
155
+ return Finding(
156
+ id=self.id,
157
+ title=title or self.title,
158
+ severity=severity or self.severity,
159
+ confidence=confidence,
160
+ standards=self.standards,
161
+ description=description,
162
+ fix=fix or self.fix,
163
+ evidence=records,
164
+ evidence_excerpt=excerpt if excerpt is not None else evidence_excerpt(records),
165
+ location=location,
166
+ details=details or {},
167
+ false_positive_notes=self.false_positive_notes or None,
168
+ )
169
+
170
+ def applies(self, ctx: ScanContext) -> bool:
171
+ return ctx.transport.name in self.applies_to
172
+
173
+
174
+ class ActiveCheck(Check):
175
+ """Base for checks that may call tools or send probe payloads.
176
+
177
+ Subclasses must call :meth:`require_active` first. The orchestrator also
178
+ refuses to run active checks without authorisation, so this is a second
179
+ line of defence inside the check itself.
180
+ """
181
+
182
+ mode = CheckMode.ACTIVE
183
+
184
+ @staticmethod
185
+ def require_active(ctx: ScanContext) -> None:
186
+ if not ctx.active_allowed:
187
+ raise PermissionError("active check requires --active with a verified authorisation record")
188
+
189
+
190
+ EXCERPT_BODY_CHARS = 240
191
+
192
+
193
+ def evidence_excerpt(records: list[EvidenceRecord]) -> str:
194
+ """One-line summary of the first evidence record: request, status and a body snippet."""
195
+ if not records:
196
+ return ""
197
+ rec = records[0]
198
+ head = rec.method
199
+ if rec.url and rec.transport == "http":
200
+ head += f" {rec.url}"
201
+ if rec.status is not None:
202
+ head += f" -> HTTP {rec.status}"
203
+ elif rec.error:
204
+ head += f" -> {rec.error}"
205
+ body = " ".join(rec.response_body.split())
206
+ if len(body) > EXCERPT_BODY_CHARS:
207
+ body = body[: EXCERPT_BODY_CHARS - 1] + "\u2026"
208
+ extra = f" (+{len(records) - 1} more)" if len(records) > 1 else ""
209
+ return f"[{rec.id}] {head}" + (f": {body}" if body else "") + extra
210
+
211
+
212
+ def evidence_summary(records: list[EvidenceRecord], summary: str) -> str:
213
+ """``[ev-0005] tools/list -> HTTP 200: <summary>`` for checks that know what matters."""
214
+ if not records:
215
+ return summary
216
+ rec = records[0]
217
+ head = rec.method + (f" -> HTTP {rec.status}" if rec.status is not None else "")
218
+ return f"[{rec.id}] {head}: {summary}"
219
+
220
+
221
+ def evidence_of(*responses: Any) -> list[EvidenceRecord]:
222
+ """Collect evidence records from RpcResponse objects, ignoring ``None``."""
223
+ out: list[EvidenceRecord] = []
224
+ for r in responses:
225
+ rec = getattr(r, "evidence", None)
226
+ if rec is not None:
227
+ out.append(rec)
228
+ return out
@@ -0,0 +1,219 @@
1
+ """META-02: canonical hashing of the server's listed surface and rug-pull diffing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from mcpprobe.checks._util import canonical_json
11
+ from mcpprobe.checks.base import Check, ScanContext
12
+ from mcpprobe.models import Confidence, Finding, Severity, Standards, utcnow
13
+
14
+ BASELINE_FORMAT_VERSION = 1
15
+
16
+
17
+ def sha256_hex(text: str) -> str:
18
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
19
+
20
+
21
+ def _key(item: dict[str, Any], fallback: str) -> str:
22
+ return str(item.get("name") or item.get("uri") or fallback)
23
+
24
+
25
+ def build_snapshot(ctx: ScanContext) -> dict[str, Any]:
26
+ """Canonical snapshot and per-item hashes of tools, prompts, resources and instructions."""
27
+ items: dict[str, Any] = {"tools": {}, "prompts": {}, "resources": {}, "instructions": None}
28
+ for kind in ("tools", "prompts", "resources"):
29
+ for idx, entry in enumerate(getattr(ctx.inventory, kind)):
30
+ items[kind][_key(entry, f"#{idx}")] = sha256_hex(canonical_json(entry))
31
+ if ctx.fingerprint.instructions:
32
+ items["instructions"] = sha256_hex(ctx.fingerprint.instructions)
33
+ snapshot = {
34
+ "tools": ctx.inventory.tools,
35
+ "prompts": ctx.inventory.prompts,
36
+ "resources": ctx.inventory.resources,
37
+ "instructions": ctx.fingerprint.instructions,
38
+ }
39
+ return {
40
+ "format": BASELINE_FORMAT_VERSION,
41
+ "target": ctx.target,
42
+ "created_at": utcnow().isoformat(),
43
+ "overall_sha256": sha256_hex(canonical_json(snapshot)),
44
+ "items": items,
45
+ "snapshot": snapshot,
46
+ }
47
+
48
+
49
+ def diff_snapshots(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
50
+ """Return added/removed/changed names per kind plus changed fields for tools."""
51
+ out: dict[str, Any] = {}
52
+ old_items = old.get("items") or {}
53
+ new_items = new.get("items") or {}
54
+ for kind in ("tools", "prompts", "resources"):
55
+ o = old_items.get(kind) or {}
56
+ n = new_items.get(kind) or {}
57
+ changed = sorted(k for k in o.keys() & n.keys() if o[k] != n[k])
58
+ entry: dict[str, Any] = {
59
+ "added": sorted(n.keys() - o.keys()),
60
+ "removed": sorted(o.keys() - n.keys()),
61
+ "changed": changed,
62
+ }
63
+ if kind == "tools" and changed:
64
+ old_tools = {_key(t, ""): t for t in (old.get("snapshot") or {}).get("tools") or []}
65
+ new_tools = {_key(t, ""): t for t in (new.get("snapshot") or {}).get("tools") or []}
66
+ fields: dict[str, list[str]] = {}
67
+ for name in changed:
68
+ a, b = old_tools.get(name, {}), new_tools.get(name, {})
69
+ fields[name] = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
70
+ entry["changed_fields"] = fields
71
+ out[kind] = entry
72
+ out["instructions_changed"] = old_items.get("instructions") != new_items.get("instructions")
73
+ out["any_change"] = out["instructions_changed"] or any(
74
+ out[k]["added"] or out[k]["removed"] or out[k]["changed"] for k in ("tools", "prompts", "resources")
75
+ )
76
+ return out
77
+
78
+
79
+ class Meta02RugPullBaseline(Check):
80
+ id = "META-02"
81
+ title = "Tool surface changed since the stored baseline (rug-pull indicator)"
82
+ severity = Severity.MEDIUM
83
+ standards = Standards(mcp=["MCP03:2025"], asi=["ASI04"])
84
+ description = (
85
+ "Computes SHA-256 over the canonical JSON of each tool, prompt and resource definition "
86
+ "and the server instructions. With --baseline FILE the current snapshot is compared to "
87
+ "the stored one and any added, removed or changed definition is reported (a changed "
88
+ "description after approval is the classic 'rug pull'). The snapshot is then written "
89
+ "back to the file. Also notes when the server advertises tools.listChanged."
90
+ )
91
+ evidence_type = "The current tools/list response plus per-item hashes and the diff."
92
+ false_positive_notes = (
93
+ "Any legitimate release that edits a description will trigger the diff; the finding "
94
+ "says what changed so the reviewer can decide. Servers that embed timestamps or random "
95
+ "data in descriptions will change on every scan."
96
+ )
97
+ fix = (
98
+ "Treat tool definitions as code: version them, review changes, and have clients pin and "
99
+ "re-approve definitions when the hash changes."
100
+ )
101
+
102
+ async def run(self, ctx: ScanContext) -> list[Finding]:
103
+ findings: list[Finding] = []
104
+ current = build_snapshot(ctx)
105
+ ctx.shared["baseline_current"] = current
106
+ ctx.notes.append(
107
+ f"META-02: surface hash {current['overall_sha256'][:16]}... "
108
+ f"({len(current['items']['tools'])} tools)"
109
+ )
110
+ caps = ctx.fingerprint.capabilities or {}
111
+ tools_caps = caps.get("tools") if isinstance(caps, dict) else None
112
+ if isinstance(tools_caps, dict) and tools_caps.get("listChanged"):
113
+ findings.append(
114
+ self.finding(
115
+ title="Server can change its tool list mid-session (listChanged)",
116
+ description="The server advertises tools.listChanged, so definitions may change "
117
+ "after a client has approved them. Clients should re-review on change.",
118
+ severity=Severity.INFO,
119
+ confidence=Confidence.HIGH,
120
+ location="capabilities.tools.listChanged",
121
+ )
122
+ )
123
+ path = ctx.options.baseline_path
124
+ if not path:
125
+ return findings
126
+ file = Path(path)
127
+ if file.exists():
128
+ try:
129
+ previous = json.loads(file.read_text("utf-8"))
130
+ except (OSError, ValueError) as exc:
131
+ ctx.notes.append(f"META-02: could not read baseline {path}: {exc}")
132
+ previous = None
133
+ if isinstance(previous, dict):
134
+ if previous.get("target") != ctx.target:
135
+ ctx.notes.append(
136
+ f"META-02: baseline target {previous.get('target')!r} differs from {ctx.target!r}"
137
+ )
138
+ diff = diff_snapshots(previous, current)
139
+ findings.extend(self._findings_from_diff(ctx, diff, previous))
140
+ if ctx.options.write_baseline:
141
+ try:
142
+ file.parent.mkdir(parents=True, exist_ok=True)
143
+ file.write_text(json.dumps(current, indent=2, sort_keys=True), "utf-8")
144
+ ctx.notes.append(f"META-02: baseline written to {path}")
145
+ except OSError as exc:
146
+ ctx.notes.append(f"META-02: could not write baseline {path}: {exc}")
147
+ return findings
148
+
149
+ def _findings_from_diff(
150
+ self, ctx: ScanContext, diff: dict[str, Any], previous: dict[str, Any]
151
+ ) -> list[Finding]:
152
+ if not diff["any_change"]:
153
+ ctx.notes.append("META-02: no change since baseline " + str(previous.get("created_at")))
154
+ return []
155
+ out: list[Finding] = []
156
+ ev = [ctx.inventory.tools_evidence] if ctx.inventory.tools_evidence else []
157
+ tools = diff["tools"]
158
+ for name in tools["changed"]:
159
+ fields = tools.get("changed_fields", {}).get(name, [])
160
+ sev = Severity.MEDIUM if ("description" in fields or "inputSchema" in fields) else Severity.LOW
161
+ out.append(
162
+ self.finding(
163
+ description=f"Tool '{name}' changed since baseline {previous.get('created_at')}: "
164
+ f"fields {', '.join(fields) or 'unknown'}.",
165
+ severity=sev,
166
+ confidence=Confidence.HIGH,
167
+ evidence=ev,
168
+ location=f"tools[{name}]",
169
+ details={"changed_fields": fields},
170
+ )
171
+ )
172
+ if tools["added"]:
173
+ out.append(
174
+ self.finding(
175
+ title="New tools appeared since baseline",
176
+ description="Added tools: " + ", ".join(tools["added"]) + ".",
177
+ severity=Severity.LOW,
178
+ confidence=Confidence.HIGH,
179
+ evidence=ev,
180
+ location="tools",
181
+ details={"added": tools["added"]},
182
+ )
183
+ )
184
+ if tools["removed"]:
185
+ out.append(
186
+ self.finding(
187
+ title="Tools removed since baseline",
188
+ description="Removed tools: " + ", ".join(tools["removed"]) + ".",
189
+ severity=Severity.INFO,
190
+ confidence=Confidence.HIGH,
191
+ evidence=ev,
192
+ location="tools",
193
+ details={"removed": tools["removed"]},
194
+ )
195
+ )
196
+ for kind in ("prompts", "resources"):
197
+ d = diff[kind]
198
+ if d["added"] or d["removed"] or d["changed"]:
199
+ out.append(
200
+ self.finding(
201
+ title=f"{kind.capitalize()} changed since baseline",
202
+ description=f"{kind}: added {d['added']}, removed {d['removed']}, changed {d['changed']}.",
203
+ severity=Severity.LOW,
204
+ confidence=Confidence.HIGH,
205
+ location=kind,
206
+ details=d,
207
+ )
208
+ )
209
+ if diff["instructions_changed"]:
210
+ out.append(
211
+ self.finding(
212
+ title="Server instructions changed since baseline",
213
+ description="The 'instructions' text returned at negotiation differs from the baseline.",
214
+ severity=Severity.MEDIUM,
215
+ confidence=Confidence.HIGH,
216
+ location="initialize.instructions",
217
+ )
218
+ )
219
+ return out
@@ -0,0 +1,171 @@
1
+ """Heuristic classification of tools into capability classes.
2
+
3
+ Used by META-03 (annotation cross-check) and to weight the severity of other
4
+ findings (an unauthenticated server exposing ``run_command`` is worse than one
5
+ exposing ``get_weather``). Annotations are *not* used as input, because the
6
+ spec says clients must treat them as untrusted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ #: Classes considered dangerous when exposed without authentication.
16
+ DANGEROUS_CLASSES: frozenset[str] = frozenset(
17
+ {"exec", "file_write", "database_write", "network", "email", "credentials", "payments"}
18
+ )
19
+
20
+ #: Classes that imply a write/side effect (should not be ``readOnlyHint: true``).
21
+ WRITE_CLASSES: frozenset[str] = frozenset(
22
+ {"exec", "file_write", "database_write", "email", "payments", "delete"}
23
+ )
24
+
25
+ _RULES: list[tuple[str, re.Pattern[str]]] = [
26
+ (
27
+ "exec",
28
+ re.compile(
29
+ r"\b(exec(ute)?|shell|bash|sh|cmd|command|run_?(command|script|code)|"
30
+ r"subprocess|powershell|terminal|eval|spawn|system)\b",
31
+ re.I,
32
+ ),
33
+ ),
34
+ (
35
+ "file_write",
36
+ re.compile(
37
+ r"\b(write_?file|save_?file|create_?file|edit_?file|move_?file|"
38
+ r"rename|chmod|mkdir|upload|overwrite|append_?file)\b",
39
+ re.I,
40
+ ),
41
+ ),
42
+ ("delete", re.compile(r"\b(delete|remove|rm|drop|purge|destroy|truncate|wipe)\b", re.I)),
43
+ (
44
+ "file_read",
45
+ re.compile(
46
+ r"\b(read_?file|cat|open_?file|list_?dir(ectory)?|ls|glob|"
47
+ r"file_?(content|path)|filesystem|directory)\b",
48
+ re.I,
49
+ ),
50
+ ),
51
+ (
52
+ "database_write",
53
+ re.compile(
54
+ r"\b(insert|update|upsert|alter|create_?table|migrate|"
55
+ r"execute_?sql|run_?sql|sql_?(exec|write))\b",
56
+ re.I,
57
+ ),
58
+ ),
59
+ (
60
+ "database_read",
61
+ re.compile(
62
+ r"\b(sql|query|select|database|db|postgres|mysql|sqlite|mongo|"
63
+ r"redis|table|schema)\b",
64
+ re.I,
65
+ ),
66
+ ),
67
+ (
68
+ "network",
69
+ re.compile(
70
+ r"\b(fetch|http|url|curl|wget|download|request|browse|crawl|scrape|"
71
+ r"webhook|api_?call|proxy|ssrf)\b",
72
+ re.I,
73
+ ),
74
+ ),
75
+ (
76
+ "email",
77
+ re.compile(
78
+ r"\b(e-?mail|smtp|send_?(mail|message|sms|slack|notification)|"
79
+ r"bcc|cc|recipient|inbox|mailbox)\b",
80
+ re.I,
81
+ ),
82
+ ),
83
+ (
84
+ "credentials",
85
+ re.compile(
86
+ r"\b(secret|token|credential|password|api_?key|ssh|private_?key|"
87
+ r"keychain|vault|oauth|\.env)\b",
88
+ re.I,
89
+ ),
90
+ ),
91
+ (
92
+ "payments",
93
+ re.compile(
94
+ r"\b(payment|pay|charge|refund|invoice|stripe|billing|transfer_?funds|"
95
+ r"wallet|crypto)\b",
96
+ re.I,
97
+ ),
98
+ ),
99
+ ("search", re.compile(r"\b(search|find|lookup|get|list|read|fetch_?info|describe|show)\b", re.I)),
100
+ ]
101
+
102
+
103
+ @dataclass(slots=True)
104
+ class Classification:
105
+ name: str
106
+ classes: list[str] = field(default_factory=list)
107
+ matched_terms: dict[str, list[str]] = field(default_factory=dict)
108
+
109
+ @property
110
+ def dangerous(self) -> bool:
111
+ return any(c in DANGEROUS_CLASSES for c in self.classes)
112
+
113
+ @property
114
+ def writes(self) -> bool:
115
+ return any(c in WRITE_CLASSES for c in self.classes)
116
+
117
+ @property
118
+ def primary(self) -> str:
119
+ return self.classes[0] if self.classes else "other"
120
+
121
+
122
+ def _text_of(tool: dict[str, Any]) -> tuple[str, str]:
123
+ """Return (name-ish text, description-ish text)."""
124
+ name_text = (
125
+ " ".join(str(tool.get(k, "")) for k in ("name", "title") if tool.get(k))
126
+ .replace("_", " ")
127
+ .replace("-", " ")
128
+ .replace(".", " ")
129
+ )
130
+ desc_parts = [str(tool.get("description", "") or "")]
131
+ schema = tool.get("inputSchema")
132
+ if isinstance(schema, dict):
133
+ props = schema.get("properties")
134
+ if isinstance(props, dict):
135
+ for pname, pdef in props.items():
136
+ desc_parts.append(str(pname).replace("_", " "))
137
+ if isinstance(pdef, dict) and pdef.get("description"):
138
+ desc_parts.append(str(pdef["description"]))
139
+ return name_text, " ".join(desc_parts)
140
+
141
+
142
+ def classify_tool(tool: dict[str, Any]) -> Classification:
143
+ """Classify one tool. Name matches weigh more than description matches."""
144
+ name_text, desc_text = _text_of(tool)
145
+ name = str(tool.get("name", ""))
146
+ scores: dict[str, int] = {}
147
+ terms: dict[str, list[str]] = {}
148
+ for cls, pattern in _RULES:
149
+ name_hits = pattern.findall(name_text + " " + name.replace("_", " "))
150
+ desc_hits = pattern.findall(desc_text)
151
+ hits = [h if isinstance(h, str) else h[0] for h in name_hits + desc_hits]
152
+ hits = [h for h in hits if h]
153
+ if not hits:
154
+ continue
155
+ score = 3 * len([h for h in name_hits if h]) + len([h for h in desc_hits if h])
156
+ scores[cls] = score
157
+ terms[cls] = sorted(set(hits))[:5]
158
+ # "search" is a weak catch-all; drop it when anything stronger matched.
159
+ if "search" in scores and len(scores) > 1:
160
+ scores.pop("search")
161
+ terms.pop("search", None)
162
+ # database_read is implied by database_write.
163
+ if "database_write" in scores and "database_read" in scores:
164
+ scores.pop("database_read")
165
+ terms.pop("database_read", None)
166
+ ordered = [c for c, _ in sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))]
167
+ return Classification(name=name, classes=ordered, matched_terms=terms)
168
+
169
+
170
+ def classify_tools(tools: list[dict[str, Any]]) -> dict[str, Classification]:
171
+ return {str(t.get("name", "")): classify_tool(t) for t in tools}