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.

Updated August 2026MCP

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:

  1. Type/value autocorrect. Any label or relationship-type token not in the live schema is resolved through a curated alias map (DOMAINHOSTNAME, IPIPV4, ASASN, …), then case/separator normalization, then nearest-match against the live set — so it self-updates as the schema changes. An ASN {name: …} value is normalized to the canonical AS<digits> form. A confident correction runs automatically and the result carries rewritten: true with a TYPE_NORMALIZED rewrite.
  2. Read-only pre-check. Write and admin clauses (CREATE / MERGE / DELETE / SET / REMOVE / FOREACH / LOAD CSV) and mutating or admin CALL procedures are rejected before anything runs — including under an EXPLAIN prefix. A negative lookbehind keeps a property accessor like n.create from tripping it.
  3. The nine safety rules, in order, first failure wins.
  4. The cost gate, which reads the query plan and rejects only a genuine blow-up.
  5. Execution.
  6. Idiom correct-and-retry. On an engine error (not a validator rejection), one bounded retry translates a recognized non-Whisper idiom (SHOW PROCEDURESCALL db.procedures(), YIELD relationshipTypeYIELD 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.

FieldMeaning
successtrue on success, false on error
errorHuman-readable error message
suggestionA concrete fix the agent can apply and retry — always set on failure
errorCodeMachine-readable code (below)
retryableWhether re-running the same query could succeed — read straight off the code

There are eleven codes:

CodeCauseRetryable
SCHEMA_ERRORBad label, property, relationship type, or column nameNo — fix the query
SYNTAX_ERRORThe Cypher itself is malformed (bad token, unknown function)No — fix the query
LIMIT_ERRORLIMIT missing, malformed, or over the 5000 cap (usually carries a CLAMP_LIMIT fix)No — fix the query
VALIDATION_REJECTEDThe query failed one of the nine safety rulesNo — fix the query
QUERY_TOO_EXPENSIVEStopped for size, not duration — the engine's element/row budget, or the cost gate classifying the plan as DECOMPOSENo — narrow it
QUERY_UNSERVABLEThe 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 edgeNo — reshape it
DEPTH_EXCEEDEDThe traversal is deeper than your plan allows. The Cypher is valid — shorten it, or read whisper://quota to see which plan you actually gotNo — shorten it
DB_TIMEOUTThe query ran past its time budgetYes — narrow it
RATE_LIMITEDA concurrency or capacity limit at the graph engine was reachedYes
ENGINE_ERRORThe engine faulted while serving the query; the response carries a request idYes
DB_UNAVAILABLEThe graph database is unreachableYes

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_ERROR code, and there never has been on this server. <!-- drift-ok: named here in order to say it does not exist --> A syntax problem classifies as SCHEMA_ERROR (bad label / property / column) or SYNTAX_ERROR (malformed Cypher), and an over-cap LIMIT as LIMIT_ERROR. If your client branches on CYPHER_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.

#RuleRejectsAllows
1Shortest-path boundunbounded shortestPath / allShortestPathsbounded [*1..N] — an unbounded [*] returns a BOUND_PATH fix proposing [*1..6]
2Limit capLIMIT > 5000LIMIT ≤ 5000; an over-cap LIMIT is auto-clamped and the query runs
3Unlabeled matchstandalone MATCH (h) with no label or anchora relationship traversal, or a {name: …} anchor
4Label disjunctionsame-variable WHERE n:A OR n:Bthe label-pipe form (n:A|B), or a property OR
5Unanchored virtual-edge scana fixed-length hop across a query-time edge with both endpoints bare — the engine cannot serve it at alleither endpoint labelled, anchored, or bound by an earlier clause; any [*1..N] form
6Unindexed text opCONTAINS / STARTS WITH / ENDS WITH on a property other than .nametext ops on .name; = on any property
7Unanchored label scanan unanchored scan of a label over 1,000,000 nodes; plus FEED_SOURCE / CATEGORY at any sizelabels under the threshold, traversals, indexed WHERE, aggregations
8id() ordering comparisonid(n) > x and the other three ordering operators — id() returns a String, so the predicate evaluates to null and the query succeeds while matching nothingid(a) = id(b), <>, ordering on ordinary properties
9Limit requiredan exploration query with no LIMITaggregations, {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:

VerdictTriggerOutcome
SAFEindexed lookup, no risky operator, or the plan is unavailable (fail-open)executes normally
PAGINATEa label scan on a label with count ≥ 1M — large but cheap and streamingexecutes normally; page the output
DECOMPOSEa Cartesian product over a large label scan, or a variable-length expansion rooted at a huge (≥100M) label scanrejected 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.

FieldWhen it's setWhat it means
autoLimited: trueYou omitted LIMIT on an exploration queryThe server appended the default LIMIT and ran it. rewrite holds the original and effective Cypher.
rewritten: trueThe server safely auto-corrected and ran the corrected formBounding rewrites that only narrow the result (CLAMP_LIMIT), and confident schema-driven corrections (TYPE_NORMALIZED).
fixA rule failed and the correction would change which rows matchReturned for the agent to apply — never auto-run. {kind, rewrittenCypher?, confidence, safeToAutoRetry}.
truncated: trueThe result hit the row capA partial, bounded result — not an unbounded set.
advisories[]The run succeeded but the engine has something to sayFor example a rate-limit penalty delay, which is how going over quota normally manifests.
engineSuggestions[]On failureThe 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_TO is forward only, HOSTNAME → IPV4. Reverse DNS is (ip)<-[:RESOLVES_TO]-(h).
  • NAMESERVER_FOR and MAIL_FOR point server → domain. A domain's MX is (domain)<-[:MAIL_FOR]-(mx).
  • LOCATED_IN is IPV4|IPV6 → CITY only. For the country, chain HAS_COUNTRY; an IPV4 → COUNTRY hop returns nothing.
  • CHILD_OF runs 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.

ProcedureWhat 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 CALLRETURN 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