Every heuristic in our static MCP manifest scanner, what each one actually checks, and an honest line between what pattern-matching on text and JSON Schema shape can catch versus what genuinely needs a running server. The limits are the point, not a caveat to bury.
We've written before about running our MCP scanner against a poisoned manifest and wiring it into CI. Both posts walked through specific findings. This one is different: it's a full accounting of every heuristic the scanner runs, and a direct answer to the question anyone evaluating a static tool should ask before trusting it — what does "clean scan" actually mean here, and where does it stop meaning anything?
The short version: sentinel-scan mcp reads a manifest as JSON and runs regexes and schema checks against it. No server process starts, no tool gets called, nothing touches the network. That's a real constraint, not a marketing framing. It means the scan is fast, free to run in CI on every PR, and safe to point at a manifest you haven't audited yet. It also means there's a whole category of MCP risk it structurally cannot see. Both halves of that trade matter, so here's the honest version of both.
All of them run inside one function that iterates a manifest's tools array (and, for the source-pinning and credential checks, its server entries too), each mapped to an OWASP LLM Top 10 category:
Regex-matches imperative override phrases in a tool's description field ("ignore previous instructions," fake [SYSTEM] tags, "do not tell the user"), plus zero-width/invisible characters, HTML comments, and base64-looking blobs hiding inside the text. Severity scales with how many independent signals fire: a zero-width character or two or more phrase matches is HIGH, a single phrase or an HTML comment is MEDIUM, a bare base64 blob alone is LOW.
What it catches: the exact class of attack Invariant Labs documented in April 2025 — hidden instructions in a description field that a UI won't render but the model's context window will. What it misses: a payload phrased in language the pattern list doesn't recognize. This is fundamentally a known-phrase detector, not a semantic one.
Three checks on tool names: exact collisions after normalizing case and punctuation; near-collisions (edit distance ≤2, only evaluated on names 6+ characters) against a fixed list of sensitive built-in tool names or against each other; and descriptions that explicitly claim to override or replace another tool. Collisions and override claims are HIGH, near-duplicates are MEDIUM.
What it catches: homoglyph typosquats like read_fiIe (capital I for lowercase l) sitting next to a legitimate read_file. What it misses: a squat on a tool name that isn't on the hardcoded sensitive-name list, or a squat with more than 2 characters of edit distance from anything it's comparing against.
Inspects inputSchema rather than description text: additionalProperties: true (MEDIUM), an object schema with no declared properties at all (LOW), an unconstrained string parameter named like a command/code field with no enum or pattern (HIGH), an unconstrained path-like parameter (MEDIUM), or a boolean parameter matching a privilege/bypass-flag name pattern like sudo, bypass, force (HIGH).
What it catches: schemas that expose functionally arbitrary execution or a safety-bypass flag to the model, even when the description undersells it. What it misses: anything the parameter name doesn't telegraph. A field called payload that takes an arbitrary shell string won't match the dangerous-param-name list; the check is keyword-based on names, not an understanding of what the handler actually does with the value.
Classifies each tool as "fetches" and/or "acts" based on keyword lists matched against name and description. A single tool doing both is HIGH (a self-contained toxic flow); separate fetch-capable and act-capable tools coexisting anywhere in the manifest is MEDIUM.
What it catches: the actual mechanism indirect prompt injection needs — a tool that reads untrusted external content sitting alongside a tool that can take an action, so a planted instruction in a fetched page can get carried out. What it misses: real data flow. It can't tell whether the fetch tool's output ever actually reaches the model's context in a way the act tool would act on, only that both capabilities exist in the same roster. It's a keyword-based capability tag, not a taint tracker.
Flags a server url using plaintext http:// (HIGH), and flags npx/uvx/pip-style runner commands that launch a package with no version pin — no @version or ==version suffix in the arg string (MEDIUM).
What it catches: the supply-chain footgun of a server config that always pulls "latest" from a package runner, which is exactly how a compromised or maliciously updated dependency reaches your agent without anyone changing your config. What it misses: whether the pinned version itself is safe. Version-pin detection is syntactic (does the string look pinned), not a registry lookup against known-vulnerable releases.
Scans a server's env dict for keys matching a secret-name pattern whose value is a literal 8+ characters (not an ${ENV_VAR} placeholder), and scans CLI args for a --flag=value/--flag value pattern with a non-placeholder secret-looking value. Both HIGH.
What it catches: the common case of a real API key or token committed directly into an MCP config instead of referenced by placeholder. What it misses: anything with a key name that doesn't look like a secret, or a value that's obfuscated, encoded, or split across fields. It's a name-and-shape heuristic, not a secret-scanning entropy analysis.
Checks a declared scopes/permissions list for any wildcard or blanket entry (*, all, admin). HIGH.
What it catches: a scope declaration that grants everything instead of what the tool actually needs. What it misses: everything, silently, if the manifest never declares a scopes field at all — which is common, since scope declaration isn't required by the MCP spec. Absence of this field means absence of this finding, not absence of overbroad access.
For a server with a remote source (package runner or URL), checks whether any of a fixed set of provenance fields — signature, checksum, publisher — is present and non-empty. If none, LOW.
What it catches: a remote server config that ships with zero stated provenance, useful as a signal to go find out where the code actually comes from. What it misses: whether a present provenance field is actually true. This checks that a signature field exists, not that the signature verifies against anything.
Classifies a tool's sensitive capability (exec, filesystem write/delete, outbound send) from keyword groups matched against name, description, and schema property names; if a sensitive capability is detected and no truthy confirmation field (like requiresConfirmation) exists on the tool or its annotations, flags at that capability's assigned severity.
What it catches: a destructive or irreversible action exposed with no declared human-in-the-loop gate in the manifest metadata. What it misses: whether the confirmation field, when present, is actually enforced by the host application at call time. This is a declarative check — the field says confirmation is required, the scanner has no way to verify the host honors it.
Scans name, description, and every schema title/description/enum string for Unicode tag-block characters (the ASCII-smuggling range used to hide invisible text in plain-looking strings), bidirectional override or embedding control characters, or zero-width characters. Tag-block or bidi hits are HIGH, zero-width-only is MEDIUM.
What it catches: text-rendering tricks that hide a full secondary instruction inside a string that looks empty or normal in a UI. What it misses: nothing outside the specific Unicode ranges checked — but that's a fixed, enumerable set, so this is one of the more complete heuristics in the group, not a probabilistic one.
Zoom out from the individual heuristics and the shared limitation is the same one stated in the tool's own docstring: this is pattern-matching on the shape of a JSON document. It has no idea what a server does when it actually runs. That's not a hedge, it's the design: static analysis trades runtime truth for zero-risk, zero-setup, zero-cost scanning you can run on an untrusted manifest before you'd ever want to execute it.
Concretely, here's what genuinely requires running the server, not reading its manifest:
read_file tool with a clean schema can still have a path-traversal bug in its implementation. No amount of manifest inspection finds that; it needs a call with a crafted argument and an observation of what comes back.That's the real dividing line between static manifest scanning and the network- or execution-dependent tools in this space: a static scanner reads what a server says it does and how it's declared to be shaped. A dynamic or runtime scanner watches what it actually does when called, at the cost of needing to run it, often against a real or sandboxed endpoint, with the operational overhead and access requirements that implies. Neither replaces the other. A clean static scan is a reasonable pre-merge gate; it's not evidence a tool is safe to grant broad access to in production.
pip install sentinel-scan-cli
sentinel-scan mcp --demo
or, no install:
pipx run sentinel-scan-cli mcp --demo
Source, and the actual regex and schema logic behind every heuristic above: github.com/Ventrova/sentinel-scan-cli.
Sentinel Scan's managed audit runs adversarial probes against your actual running system, chat input, RAG retrieval, and tool outputs, graded by an independent judge, mapped to OWASP LLM Top 10 & NIST AI 600-1, in 24 hours.
Sources cited: OWASP GenAI LLM01:2025, Prompt Injection, OWASP GenAI LLM06:2025, Excessive Agency, OWASP GenAI LLM03:2025, Supply Chain, Invariant Labs' April 2025 MCP tool poisoning writeup, and the sentinel_scan.py source in our own repo.