Skip to content
Agents & MCP
Skip navigation
Agents & MCP

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.

Published

View as Markdown
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:

  1. Input-length pre-check. A cypher string longer than 32,768 characters is rejected outright — checked first, ahead of autocorrect and ahead of the ten rules, and applied under an EXPLAIN prefix too. The query is never shortened and run for you.
  2. 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.
  3. 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.
  4. The ten safety rules, in order, first failure wins.
  5. The cost gate, which reads the query plan and rejects only a genuine blow-up.
  6. Execution.
  7. 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.

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.

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 is missing or malformed (usually carries a CLAMP_LIMIT fix)No — fix the query
VALIDATION_REJECTEDThe 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 fixNo — 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 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 runsNo — shorten it
DB_TIMEOUTThe query ran past its time budgetYes — narrow it
RATE_LIMITEDThe graph engine declined to serve the query at that momentYes
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. A syntax problem classifies as SCHEMA_ERROR (bad label / property / column) or SYNTAX_ERROR (malformed Cypher), and a LIMIT the engine will not serve as LIMIT_ERROR. If your client branches on CYPHER_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.

#RuleRejectsAllows
1Shortest-path boundunbounded shortestPath / allShortestPathsbounded [*1..N] — an unbounded [*] returns a BOUND_PATH fix proposing [*1..6]
2Limit clampnothing — this rule never rejectsany LIMIT; one the engine will not serve 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
6Untyped prefix expansiona fixed-length, untyped outgoing expansion from an ANNOUNCED_PREFIX or REGISTERED_PREFIX anchor — a shape the engine cannot servea typed relationship (-[r:ROUTES]->), an expansion into the anchor (<-[r]-), any [*1..N] form, or a PREFIX-labelled anchor
7Unindexed text opCONTAINS / STARTS WITH / ENDS WITH on a property other than .nametext ops on .name; = on any property
8Unanchored label scanan unanchored scan of a large label; plus FEED_SOURCE / CATEGORY at any sizesmall labels, traversals, indexed WHERE, aggregations
9id() 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
10Limit 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 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:

VerdictTriggerOutcome
SAFEindexed lookup, no risky operator, or the plan is unavailable (fail-open)executes normally
PAGINATEa scan of a large label — big but cheap and streamingexecutes normally; page the output
DECOMPOSEa Cartesian product over a large label scan, or a variable-length expansion rooted at one of the billion-scale labelsrejected 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.

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 server returned a bounded prefix rather than the whole resultA partial result that looks complete unless you read this field.
advisories[]The run succeeded but the engine has something to sayFor 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 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 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_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 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 (AS13335ARIN). 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.

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 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 coverage before band. 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.explain does not return coverage at 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 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