Query language
The query tool's contract: the typed error envelope and its eleven error codes, the nine query-safety rules, the self-correction fields, the procedures callable inside Cypher, and the traversal landmines that return a wrong answer rather than an error.
Query language Documentation
Everything about the query tool below the level of "what it returns". For the tool's arguments and response shape, see the Reference; for a worked investigation that uses it, see Your first investigation.
What happens to a query
In order:
- Type/value autocorrect. Any label or relationship-type token not in the live schema is resolved through a curated alias map (
DOMAIN→HOSTNAME,IP→IPV4,AS→ASN, …), then case/separator normalization, then nearest-match against the live set — so it self-updates as the schema changes. AnASN {name: …}value is normalized to the canonicalAS<digits>form. A confident correction runs automatically and the result carriesrewritten: truewith aTYPE_NORMALIZEDrewrite. - Read-only pre-check. Write and admin clauses (
CREATE/MERGE/DELETE/SET/REMOVE/FOREACH/LOAD CSV) and mutating or adminCALLprocedures are rejected before anything runs — including under anEXPLAINprefix. A negative lookbehind keeps a property accessor liken.createfrom tripping it. - The nine safety rules, in order, first failure wins.
- The cost gate, which reads the query plan and rejects only a genuine blow-up.
- Execution.
- Idiom correct-and-retry. On an engine error (not a validator rejection), one bounded retry translates a recognized non-Whisper idiom (
SHOW PROCEDURES→CALL db.procedures(),YIELD relationshipType→YIELD type) and re-runs once.
The query path is provably read-only. The sanctioned write path is the two dedicated submit_* tools, never query.
Error model
query returns a typed error envelope that clients and LLMs can branch on instead of parsing free text.
| Field | Meaning |
|---|---|
success | true on success, false on error |
error | Human-readable error message |
suggestion | A concrete fix the agent can apply and retry — always set on failure |
errorCode | Machine-readable code (below) |
retryable | Whether re-running the same query could succeed — read straight off the code |
There are eleven codes:
| Code | Cause | Retryable |
|---|---|---|
SCHEMA_ERROR | Bad label, property, relationship type, or column name | No — fix the query |
SYNTAX_ERROR | The Cypher itself is malformed (bad token, unknown function) | No — fix the query |
LIMIT_ERROR | LIMIT missing, malformed, or over the 5000 cap (usually carries a CLAMP_LIMIT fix) | No — fix the query |
VALIDATION_REJECTED | The query failed one of the nine safety rules | No — fix the query |
QUERY_TOO_EXPENSIVE | Stopped for size, not duration — the engine's element/row budget, or the cost gate classifying the plan as DECOMPOSE | No — narrow it |
QUERY_UNSERVABLE | The engine refused to plan the shape at all — an unanchored full-label scan, a global edge count, or an unanchored traversal of a query-time edge | No — reshape it |
DEPTH_EXCEEDED | The traversal is deeper than your plan allows. The Cypher is valid — shorten it, or read whisper://quota to see which plan you actually got | No — shorten it |
DB_TIMEOUT | The query ran past its time budget | Yes — narrow it |
RATE_LIMITED | A concurrency or capacity limit at the graph engine was reached | Yes |
ENGINE_ERROR | The engine faulted while serving the query; the response carries a request id | Yes |
DB_UNAVAILABLE | The graph database is unreachable | Yes |
QUERY_TOO_EXPENSIVE is the size guard, distinct from DB_TIMEOUT's duration guard — a query can be cheap per row and still touch too many elements.
There is no
CYPHER_SYNTAX_ERRORcode, and there never has been on this server. <!-- drift-ok: named here in order to say it does not exist --> A syntax problem classifies asSCHEMA_ERROR(bad label / property / column) orSYNTAX_ERROR(malformed Cypher), and an over-capLIMITasLIMIT_ERROR. If your client branches onCYPHER_SYNTAX_ERROR, that branch is dead code.
The MCP server never returns HTTP 429. It applies no rate or usage limiting of its own — a request is 401 (auth failure) or 503 (auth backend degraded). A 429 you see in this stack came from the graph engine's own concurrency guard and carries the engine's {quota: {plan, maxConcurrent, current}} shape.
The nine safety rules
The validator runs them in order; the first failure wins; an EXPLAIN <query> is exempt. String literals, // and /* */ comments, and backtick-quoted identifiers are stripped before matching, so a value or a comment can never trip a rule.
| # | Rule | Rejects | Allows |
|---|---|---|---|
| 1 | Shortest-path bound | unbounded shortestPath / allShortestPaths | bounded [*1..N] — an unbounded [*] returns a BOUND_PATH fix proposing [*1..6] |
| 2 | Limit cap | LIMIT > 5000 | LIMIT ≤ 5000; an over-cap LIMIT is auto-clamped and the query runs |
| 3 | Unlabeled match | standalone MATCH (h) with no label or anchor | a relationship traversal, or a {name: …} anchor |
| 4 | Label disjunction | same-variable WHERE n:A OR n:B | the label-pipe form (n:A|B), or a property OR |
| 5 | Unanchored virtual-edge scan | a fixed-length hop across a query-time edge with both endpoints bare — the engine cannot serve it at all | either endpoint labelled, anchored, or bound by an earlier clause; any [*1..N] form |
| 6 | Unindexed text op | CONTAINS / STARTS WITH / ENDS WITH on a property other than .name | text ops on .name; = on any property |
| 7 | Unanchored label scan | an unanchored scan of a label over 1,000,000 nodes; plus FEED_SOURCE / CATEGORY at any size | labels under the threshold, traversals, indexed WHERE, aggregations |
| 8 | id() ordering comparison | id(n) > x and the other three ordering operators — id() returns a String, so the predicate evaluates to null and the query succeeds while matching nothing | id(a) = id(b), <>, ordering on ordinary properties |
| 9 | Limit required | an exploration query with no LIMIT | aggregations, {name: …}/.name = anchored queries, EXPLAIN — and a missing LIMIT is auto-injected rather than rejected |
Two further guards run alongside the rules rather than as rules, because they depend on the bound parameters rather than the query text: a null LIMIT/SKIP parameter (which the engine reads as unbounded, silently) and an UNWIND over five or more values feeding a CALL {} subquery. Both return a typed error with a fix.
The cost gate
A query can pass all nine rules and still be a blow-up — a Cartesian product from accidentally-disconnected patterns, or an unbounded variable-length expansion off a huge label scan, only shows up in the plan. The gate fetches that plan (an EXPLAIN; nothing executes) and classifies it:
| Verdict | Trigger | Outcome |
|---|---|---|
SAFE | indexed lookup, no risky operator, or the plan is unavailable (fail-open) | executes normally |
PAGINATE | a label scan on a label with count ≥ 1M — large but cheap and streaming | executes normally; page the output |
DECOMPOSE | a Cartesian product over a large label scan, or a variable-length expansion rooted at a huge (≥100M) label scan | rejected as QUERY_TOO_EXPENSIVE with the plan-derived reason |
Only DECOMPOSE is rejected, because paging cannot help: the engine materializes the join or expansion before any LIMIT trims it. Rewrite it — anchor a node, connect the patterns, or stage the traversal. The gate is fail-open end to end, so it can only add a rejection on top of the nine rules, never silently drop a query that would otherwise have run.
Self-correcting queries
The validator does more than reject. Where it safely can, it bounds or rewrites the query, runs it anyway, and tells you what it did.
| Field | When it's set | What it means |
|---|---|---|
autoLimited: true | You omitted LIMIT on an exploration query | The server appended the default LIMIT and ran it. rewrite holds the original and effective Cypher. |
rewritten: true | The server safely auto-corrected and ran the corrected form | Bounding rewrites that only narrow the result (CLAMP_LIMIT), and confident schema-driven corrections (TYPE_NORMALIZED). |
fix | A rule failed and the correction would change which rows match | Returned for the agent to apply — never auto-run. {kind, rewrittenCypher?, confidence, safeToAutoRetry}. |
truncated: true | The result hit the row cap | A partial, bounded result — not an unbounded set. |
advisories[] | The run succeeded but the engine has something to say | For example a rate-limit penalty delay, which is how going over quota normally manifests. |
engineSuggestions[] | On failure | The engine's own remediation list. An entry whose rewrite is runnable Cypher is also promoted into fix; an illustrative one is relayed but not offered as executable. |
Two classes of correction. Bounding rewrites run automatically — they trim the result to a bounded prefix, same rows, just capped. Semantic rewrites come back as a fix and are never auto-run, because they change which rows match: EXACT_MATCH (an unindexed CONTAINS → =), BOUND_PATH, and ADD_LABEL / PICK_LABEL / ANCHOR_MATCH, which carry no rewrittenCypher because they need a value only you have.
No validator rejection is ever a bare error. It is auto-fixed, auto-bounded, or it carries a fix.
Traversal landmines
These return a wrong answer rather than an error, which makes them worth more attention than the rules above.
Sorting or aggregating the feed→category chain returns zero rows
Measured on production 2026-08-09, reproducible on an unlimited-depth key:
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)-[:BELONGS_TO]->(cat:CATEGORY)
RETURN f.id AS feed, cat.id AS category
LIMIT 20
→ 6 rows, correct.
Add ORDER BY category, feed — or replace the projection with count(*) / collect(...) — and the same query returns zero rows with success: true. No error, no advisory. Each hop works alone under ORDER BY, and another two-hop virtual chain (IPV4 → PREFIX ← ASN) is unaffected.
Until it is fixed, sort client-side or stage the hops apart:
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)
WITH collect(f.id) AS feeds
MATCH (fs:FEED_SOURCE)-[:BELONGS_TO]->(c:CATEGORY) WHERE fs.id IN feeds
RETURN fs.id AS feed, c.id AS category ORDER BY category, feed
Tracked as whisper-dbj-ng#1492.
Edge direction
Four edges point the opposite way to intuition, and traversing them backwards returns an empty result rather than an error:
RESOLVES_TOis forward only,HOSTNAME → IPV4. Reverse DNS is(ip)<-[:RESOLVES_TO]-(h).NAMESERVER_FORandMAIL_FORpoint server → domain. A domain's MX is(domain)<-[:MAIL_FOR]-(mx).LOCATED_INisIPV4|IPV6 → CITYonly. For the country, chainHAS_COUNTRY; anIPV4 → COUNTRYhop returns nothing.CHILD_OFruns child → parent (HOSTNAME → HOSTNAME → TLD).
Anchor feeds and categories on .id
.name carries a display form that varies, so a filter on it can silently return nothing. Filter and project on .id, the stable slug (c2, tor, ad-tracking). And never scan FEED_SOURCE or CATEGORY directly — rule 7 rejects it at any size; reach them via LISTED_IN from an anchored node.
CONTAINS at scale
STARTS WITH and ENDS WITH on .name are indexed and fast at any scale. CONTAINS on .name is allowed, but with no LIMIT or a low-selectivity substring it degrades to a whole-label scan and hits the execution deadline. On ASN.name specifically it times out — use STARTS WITH "AS…". Pair an unanchored CONTAINS with an anchoring predicate.
Virtual edges need one anchored endpoint
About two thirds of the 50 edge types are synthesized at query time — including ROUTES, HAS_NAME, CONFLICTS_WITH, ANNOUNCED_BY, LISTED_IN, TAGGED_AS, BGP_PATH, HAS_COUNTRY, LOCATED_IN, and the physical-infrastructure and RPKI edges. They traverse normally, including inside [*1..N], but a fully bare MATCH (a)-[:TYPE]->(b) is rejected (rule 5) rather than returned empty.
Labels that exist but have no edges
RDAP_ENTITY, DNS_ROOT_INSTANCE and DWI_DOMAIN are node-only: a traversal from them returns zero rows because there is nothing to traverse, not because your pattern is wrong. RIR is retired and nothing reaches it.
Procedures callable inside query
Beyond the dedicated tools, query accepts these procedures directly inside Cypher, so you can compose them into a larger query.
| Procedure | What it does |
|---|---|
CALL explain("indicator") | Threat assessment for an IP / hostname / ASN / CIDR — also the explain_indicator tool |
CALL whisper.history("indicator") | Historical WHOIS / BGP snapshots. whisper.history.whois(...) and whisper.history.bgp(...) take one arm at a time |
CALL whisper.variants("name" [, "LABEL"] [, checkExisting]) | Typosquat / lookalike variant generation |
CALL whisper.identify($hosts) | Host identity — vendor, canonical name, host_class |
CALL whisper.assess($hosts) | Coverage-qualified clean/suspicious verdict per host |
CALL whisper.walk($host, $depth, $budgetMs) | Structural neighbourhood when identify has no direct match |
CALL whisper.origins($domain) | Candidate true-origin IPs behind a CDN or proxy |
CALL whisper.search("token") | Bounded, type-aware lookup of an unclassified token (IP / host / ASN / CIDR / prefix / suffix) instead of an unanchored scan |
CALL whisper.explain.bundle(...) | Several explain arms in one round-trip |
CALL whisper.lookupTorRelay(...) · whisper.lookupTlsFingerprint(...) | Tor-relay and TLS-fingerprint lookups |
CALL whisper.psl.tldPlusOne(...) · whisper.psl.isPublicSuffix(...) | Public-suffix arithmetic on a hostname |
CALL whisper.topAsnsByPrefixCount(...) | Ranked ASNs by announced-prefix count |
CALL whisper.audit.malformedHostnames(...) | Data-quality sweep for malformed hostnames |
CALL whisper.quota() | The caller's plan and limits — the same data as whisper://quota, as key/value rows |
CALL db.labels() · db.relationshipTypes() · db.schema("json") | Schema introspection |
whisper.assess and whisper.walk are procedures, not tools — their tool-level equivalents were folded into explain_indicator and identify, but the CALL form is still live and still the right way to compose them inside a larger query.
whisper.variants() also works in expression position, not only as a top-level CALL — RETURN size(whisper.variants("paypal.com")) works. explain() and whisper.history() are CALL-only.
For a packaged, multi-step version of any of these, run a gallery workflow with run_workflow.
Next
- Reference — the tools themselves, with input shapes and response fields.
- Cypher guide — the language reference, functions, and the cookbook.
- Your first investigation — these rules applied to one real alert.