Query language
The query tool's contract: the typed error envelope and its eleven error codes, the ten 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.
On this page (13)
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:
- Input-length pre-check. A
cypherstring longer than 32,768 characters is rejected outright — checked first, ahead of autocorrect and ahead of the ten rules, and applied under anEXPLAINprefix too. The query is never shortened and run for you. - 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 ten 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.
Step 3 is what makes the whole server read-only, because query is the only place a caller supplies Cypher at all — the prepared workflows behind run_workflow take a slug and parameter values, never a query string. There is no write path anywhere on the surface: no tool writes to the graph, under any scope or deployment, and every one of the seven attests readOnlyHint: true / destructiveHint: false. whisper.submit and whisper.watch are live procedures on the graph engine, but nothing here calls them and the pre-check denies them by name.
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 is missing or malformed (usually carries a CLAMP_LIMIT fix) | No — fix the query |
VALIDATION_REJECTED | The query failed one of the ten safety rules — or the 32,768-character input cap that runs ahead of them, which carries a SHORTEN_QUERY fix | 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 engine refused the traversal for its length, not its syntax. The Cypher is valid: shorten it, or read whisper://quota for what your key allows. It is never an authentication problem — an unrecognised key is rejected with a 401 before any tool runs | No — shorten it |
DB_TIMEOUT | The query ran past its time budget | Yes — narrow it |
RATE_LIMITED | The graph engine declined to serve the query at that moment | 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. A syntax problem classifies asSCHEMA_ERROR(bad label / property / column) orSYNTAX_ERROR(malformed Cypher), and aLIMITthe engine will not serve asLIMIT_ERROR. If your client branches onCYPHER_SYNTAX_ERROR, that branch is dead code.
The MCP server does not throttle. A failure at its own layer is 401 (auth failure) or 503 (auth backend degraded); anything else reached you from the graph engine underneath it.
The ten 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.
Ahead of all ten sits the 32,768-character input cap on the raw query text. It is not one of the rules and nothing exempts it — an EXPLAIN is measured the same way, and so is a body that is mostly comment, because the cap is applied before anything is stripped. Over-length comes back as VALIDATION_REJECTED with a SHORTEN_QUERY fix; split the query into smaller anchored steps.
| # | 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 clamp | nothing — this rule never rejects | any LIMIT; one the engine will not serve 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 | Untyped prefix expansion | a fixed-length, untyped outgoing expansion from an ANNOUNCED_PREFIX or REGISTERED_PREFIX anchor — a shape the engine cannot serve | a typed relationship (-[r:ROUTES]->), an expansion into the anchor (<-[r]-), any [*1..N] form, or a PREFIX-labelled anchor |
| 7 | Unindexed text op | CONTAINS / STARTS WITH / ENDS WITH on a property other than .name | text ops on .name; = on any property |
| 8 | Unanchored label scan | an unanchored scan of a large label; plus FEED_SOURCE / CATEGORY at any size | small labels, traversals, indexed WHERE, aggregations |
| 9 | 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 |
| 10 | 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 ten 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 scan of a large label — big 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 one of the billion-scale labels | 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 ten 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 server returned a bounded prefix rather than the whole result | A partial result that looks complete unless you read this field. |
advisories[] | The run succeeded but the engine has something to say | For example projection-verdict-omitted, which says a whole-node projection left the reconciled verdict fields out and tells you how to ask for them. |
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 fewer of them. 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.
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 8 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
Most of the 52 edge types, roughly two thirds, are synthesized at query time in whole or in part — 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
DNS_ROOT_INSTANCE, DWI_DOMAIN and RIR are node-only: a traversal from them returns zero rows because there is nothing to traverse, not because your pattern is wrong. RIR in particular is listable and correct — its five nodes are AFRINIC, APNIC, ARIN, LACNIC and RIPENCC — and still unjoinable: no edge reaches it, so you cannot walk from a prefix or an ASN to its registry. Read the registry off the node you already have instead: ASN carries it as autNumSourceRir (AS13335 → ARIN). RDAP_ENTITY used to be on this list and no longer is: a prefix or an ASN reaches its registrant handle through REGISTERED_TO_ENTITY.
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 verdict per host — a list or a single string; a URL folds to its host. Columns host, label, band, sub_labels, signals, coverage, evidence, verdictScore |
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, each with a 0–1 confidence and the methods that found it |
CALL whisper.enrich($indicators) | Owner, country, ASN, band and prevalence for a list of hosts or IPs in one call — rows are keyed by name, not by input position |
CALL whisper.resolve("host") | The A and AAAA records the graph holds for a host, with the freshest observation time and a coverage value |
CALL whisper.danglingCname($hosts) | CNAME targets whose apex is unregistered — the subdomain-takeover check; zero rows on a clean host |
CALL whisper.cve.byPackage("cpe:2.3:…") · whisper.vulnPosture(...) | Known CVEs for a CPE 2.3 package spec, and a one-row exposure roll-up for a CVE list, a package spec or an ASN |
CALL whisper.export({label: "malicious", limit: 1000}) | Paged bulk export of a verdict tier (malicious, ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways., benign-allowlisted); continue with the returned next_cursor |
CALL whisper.asnThreatDensity("AS…") · whisper.asnCountries(n) · whisper.bgpDegreeDistribution() | Listed-IP density for one network, ASN counts per country, and the peering-degree histogram |
CALL whisper.version() | Version and build time of the engine that answered — the cheapest liveness probe |
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 own service context as key/value rows — the same data the whisper://quota resource carries |
CALL db.labels() · db.relationshipTypes() · db.schema("json") | Schema introspection |
Read
coveragebeforeband. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
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.