An agent asks your list tool for the open tickets owned by two people and gets back an empty array.
Its call was well formed, so the tool ran it: the single owner property took one name, or the
query string took or for a word in a title. The agent reads the empty list as the answer, reports
that nothing matches, and moves on.
What you get
You will end up with three argument designs for one list tool, a harness that scores them on ten prompts, and a handler that reports what it applied and rejects unknown keys. This is for you if agents call a list tool you own.
Short answer
Give the list tool a filter object with one key per field you index, lists where several values
may match, and a sort object whose field and direction are enums. Set additionalProperties
to false at every level, so an unknown key returns an error naming the key instead of an empty
list. Return the applied filters beside the rows. A query mini-syntax expresses more, but a syntax
slip comes back as an empty result unless the parser refuses it.
You will need
Node 22 or later, and a list or search tool with at least one filter. Verified 2026-10-21 against
Node 22.22.0 and Ajv 8.20.0. The designs are written as
MCP tool definitions, whose
inputSchema is a JSON Schema, and Ajv validates every call against
them. The sample runs offline over twelve tickets and calls no model.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A documented query-string mini-syntax | Filters that combine freely, with exclusions and alternatives in one call | A grammar to spell out in the description, a parser to keep strict, and an empty result for every slip the parser lets through | The filters are a handful of fields with fixed vocabularies |
| A filter object with sort field and direction enums | Several fields, several values per field, and a store with a fixed set of sortable columns | The largest schema of the three on every turn, and no exclusion without another property to carry it | One filter, where a single flat property is shorter and as clear |
| Named filter properties in the schema | Two or three filters that never combine as alternatives, on a tool that already exists | One property per operator you add, one value per field per call, and a free-text sort that reaches any column | Users ask for two owners, or for everything except one state |
The query string is the smallest schema here, 700 characters against 773 and 1,021, because its grammar lives in one description rather than in properties. It is also the only design that expresses all ten prompts. The parser is what it costs, and a slip in the grammar costs the agent an empty list.
Write the three schemas against one store
The store is twelve tickets in tickets.json, with an index on created_at, priority, and
title. Each design is a tool definition with the same name and a different inputSchema, so an
agent sees one at a time. The filter object carries the most structure.
filter: {
name: 'tickets_list',
description: 'List tickets. Every key in filter narrows the result, and a list matches any of its values. Sort by one indexed column.',
inputSchema: {
type: 'object',
properties: {
filter: {
type: 'object',
properties: {
status: { type: 'array', items: { type: 'string', enum: STATUSES }, description: 'Any of these states.' },
owner: { type: 'array', items: { type: 'string' }, description: 'Any of these owner logins.' },
priority: { type: 'array', items: { type: 'string', enum: PRIORITIES }, description: 'Any of these priorities.' },
created_after: { type: 'string', pattern: DATE, description: 'Only tickets created after this date, YYYY-MM-DD, exclusive.' },
text: { type: 'string', description: 'Words that must all appear in the title, in any order.' },
},
additionalProperties: false,
},
sort: {
type: 'object',
properties: {
field: { type: 'string', enum: SORT_FIELDS, description: 'The indexed columns, which are the only ones you can sort by.' },
direction: { type: 'string', enum: ['asc', 'desc'] },
},
required: ['field', 'direction'],
additionalProperties: false,
},
},
additionalProperties: false,
},
},
status and priority are arrays over an enum, so “open or in progress” is one call, and the
closed vocabulary lets the agent write “not closed” as the two states that remain. owner is an
array of free strings, because logins are an open set. Excluding an owner therefore stays out of
reach: the agent cannot list the complement of a set it has never seen. sort.field is an enum
of the three indexed columns, the only place the store’s indexes appear in anything the agent reads.
The named design flattens the same fields into one property each and leaves sort as a free
string. The query design has one property, whose description is the grammar.
Anthropic’s tool definition guidance
asks for at least three to four sentences of description, and for format-sensitive input it offers
an input_examples field. An MCP tool definition has no such field, so the JSON Schema examples
keyword carries them here.
query: {
type: 'string',
description: [
'Clauses separated by spaces, all of which must match. A clause is field:value with field one of',
'status (open, in_progress, closed), owner (a login), priority (low, normal, high) or created_after (YYYY-MM-DD, exclusive).',
'Prefix a clause with - to exclude it. Join two clauses on the same field with OR, in capitals, to match either.',
'Bare words must all appear in the title. sort:FIELD-DIRECTION orders the result, with FIELD one of',
'created_at, priority, title and DIRECTION asc or desc.',
].join(' '),
examples: ['status:open owner:ana sort:created_at-desc', 'invoice export -status:closed'],
},
Every field with a fixed vocabulary lists it, the date says whether it is inclusive, and the one operator with a casing rule says so. The Lucene query parser documentation is blunt about the rest. A parser of this kind is designed for human-entered text, and a field with a limited set of values belongs in a typed clause rather than in a string to be parsed.1
Score the designs on the same ten prompts
A prompt means a set of rows, in an order when it asks for one. prompts.json carries that intent
in the store’s own query shape, so the intended rows are computed rather than typed, and
calls.json carries one call per prompt per design. A call scores when it returns exactly the
intended rows.
/** ok, or what came back instead: extra rows, missing rows, nothing, or an error. */
export function score(intended, result) {
if (result.error) return 'error'
const got = result.rows
if (got.join() === intended.join()) return 'ok'
if (!got.length) return 'empty'
const extra = got.filter((id) => !intended.includes(id)).length
const missing = intended.filter((id) => !got.includes(id)).length
return extra || missing ? `${extra ? `+${extra}` : ''}${missing ? `-${missing}` : ''}` : 'order'
}
node measure.mjs
prompt named query filter
open tickets ok ok ok
tickets owned by ana ok ok ok
open tickets owned by ana or ben -2 ok ok
high priority tickets, newest first ok ok ok
tickets created after 2026-03-01 ok ok ok
closed tickets not owned by ben +1 ok +1
tickets about the invoice export ok ok ok
ana's open tickets, highest priority first ok ok ok
everything that is not closed -3 ok ok
open tickets, oldest first ok ok ok
intended rows first time 7/10 10/10 9/10
schema size in characters 773 700 1021
The calls were written by hand as the most literal correct call for each prompt. The table
therefore measures what each design can express, not how often a model fills it in correctly, and
the query design’s ten of ten assumes every clause was spelled right. To measure the agent, record
its calls from ten real prompts and drop them into calls.json; the harness scores whatever is
there. That is the loop
Anthropic’s guidance on writing tools for agents
describes.
Named properties hold one value per field, so “ana or ben” loses two rows and “not closed” loses three. The filter object has lists and no negation, so “not owned by ben” returns one closed ticket too many. That cell is the trade-off in miniature: a property per operator, or an operator the agent has to spell.
Report what you applied, and refuse what you do not know
Every result carries applied_filters, the normalized filter the store ran, beside the rows. With
it, the agent that sent owner:ana or owner:ben can see "text":["or"] and correct itself.
Without it, the empty list is all the evidence it has.
JSON Schema’s additionalProperties,
set to false, fails validation for any key that properties does not name, and Ajv reports which
one in params.additionalProperty. The handler turns that into an error the agent can act on.
/** One Ajv error as data an agent can act on: the key, where it sat, and what was allowed. */
export function explain(e) {
const path = e.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'arguments'
if (e.keyword === 'additionalProperties') {
const allowed = Object.keys(e.parentSchema.properties ?? {})
const key = e.params.additionalProperty
return { code: 'unknown_key', key, path, allowed, message: `unknown key "${key}" in ${path}; allowed keys are ${allowed.join(', ')}` }
}
if (e.keyword === 'enum') {
const allowed = e.params.allowedValues
return { code: 'invalid_value', path, allowed, message: `${path} must be one of ${allowed.join(', ')}` }
}
return { code: 'invalid_argument', path, message: `${path} ${e.message}` }
}
An enum miss gets the same treatment, with the allowed values in the error.
OpenAI’s strict function calling
requires the same additionalProperties: false on every object, plus every property in
required. The MCP specification reaches for the keyword on a tool with no parameters at all.2
Make the parser refuse a guess
The lenient parser in query-syntax.mjs is the one that gets written first: anything that is not a
recognized clause is a word to look for in the title. The strict mood refuses any token it cannot
honor, and names it: a lowercase operator, a comparator, a quoted phrase, a minus on a word, or an
OR with no clause after it.
if (strict && /^(or|and|not)$/i.test(token)) {
return fail('bad_operator', token, `"${token}" is not an operator; write OR in capitals between two clauses`)
}
if (strict && /[:=<>]/.test(token)) {
return fail('bad_clause', token, `"${token}" is not a clause; a clause is field:value with field one of ${FIELDS.join(', ')}`)
}
if (strict && orPending) {
return fail('bad_operator', token, 'OR joins two clauses on the same field, as in owner:ana OR owner:ben')
}
if (strict && !/^[a-z0-9]+$/i.test(token)) {
return fail('bad_word', token, `"${token}" is not a title word; a word is letters and digits, with no quotes and no leading -`)
}
push(where, 'text', token.toLowerCase())
Stripe’s search API takes the same position and returns an error
for an unsupported operator rather than an empty page.3 The strictness extends to values:
status:Open is refused with the three values that would have worked. A field given twice,
owner:ana owner:ben, is refused too, because a space means AND in this grammar and OR in
Lucene’s. The cost is the one every strict parser carries. Text with a colon, a quote, or a hyphen
in it cannot be searched until the grammar learns quoting or the tool grows a text field.
Check it worked
The first test is the one the design turns on: a malformed filter returns an error naming the offending key, not an empty array.
test('an unknown filter key is an error that names the key, not an empty list', () => {
const result = call('filter', { filter: { owner_name: 'ana' } }, { store })
assert.equal(result.rows, undefined)
assert.equal(result.error.code, 'unknown_key')
assert.equal(result.error.key, 'owner_name')
assert.match(result.error.message, /owner_name/)
assert.ok(result.error.allowed.includes('owner'))
})
node --test tools.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 211.815321
The last test asserts the counts in the table, 7, 10, and 9, so a schema change that alters
what a design can express fails here before it reaches an agent.
When it goes wrong
The query design answers a slip with an empty list and no complaint. Both slips below are guesses
the grammar invites: a lowercase or, and a comparator borrowed from GitHub’s syntax, where
created:>2026-03-01 is how a date bound is written.4 The lenient parser reads each as a title
word, and the strict one names it.
node pitfall.mjs
query "status:open owner:ana or owner:ben"
lenient rows: []
lenient applied_filters: {"status":["open"],"owner":["ana","ben"],"text":["or"]}
strict error: "or" is not an operator; write OR in capitals between two clauses
query "created>2026-03-01"
lenient rows: []
lenient applied_filters: {"text":["created>2026-03-01"]}
strict error: "created>2026-03-01" is not a clause; a clause is field:value with field one of status, owner, priority, created_after
sort by a column with no index
named design, sort "updated_at desc": {"rows":12,"plan":"full sort of 12 rows, no index on updated_at"}
filter design, sort.field "updated_at": sort.field must be one of created_at, priority, title
The sort pitfall is the second half of that output. The named design’s free string let
updated_at desc through, and the store did a full sort on a column with no index. That is instant
on twelve rows, but the same full sort is the query that times out on twelve million. The filter design never let the
request reach the store, and its error lists the three columns that would have worked.
When not to do this
Do not build a mini-syntax for three fields with fixed vocabularies. Lucene’s documentation says that enumerable values belong in typed clauses, and an agent generating a query string is the program-generated text that note warns about.
Do not reject an unknown key without naming the keys that exist. An error that says only “invalid arguments” leaves the agent to guess again, and the second guess is as likely to be wrong as the first.
Do not let a sort argument name a column your store cannot serve. The enum on sort.field is the
list of indexed columns, and keeping the two in step is a maintenance duty cheaper than a timeout
the agent cannot explain.
Do not read the ten of ten as a verdict on agents. The table measures what each design can express with a correct call. A transcript of real calls measures the agent, and that is the number to decide on, because the design that expresses the most has the most ways to be wrong.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0 and Ajv 8.20.0. Every output block is what the command
preceding it printed. The ten calls per design in calls.json were written by hand as the most
literal correct call for each prompt, so the counts measure what each design expresses. No model
was run.
Footnotes
-
The query parser syntax page cautions, before it explains a single operator, that the parser is designed for human-entered text, not for program-generated text. A field with a limited set of values belongs in a TermQuery clause rather than in a string to be parsed, it adds. The Boolean operators must be ALL CAPS, which is the rule the lowercase
oron this page broke. Agents were not the program the authors had in mind. They are the program the advice fits. ↩︎ Back to text -
The MCP specification recommends
{ "type": "object", "additionalProperties": false }as the input schema for a tool that takes no parameters, glossing it as explicitly accepting only empty objects. The keyword is at work before there is a single property to protect. ↩︎ Back to text -
Stripe’s search query language allows up to ten clauses, joined with
ANDor withORbut never both in one query, and offers no parentheses. An unsupported operator, such as a greater-than on a string field, returns an error. The whole grammar fits in a few lines, which is about the size a grammar has to be before an agent can hold it from a description. ↩︎ Back to text -
GitHub’s search syntax excludes a qualifier with a leading hyphen and a keyword with
NOT, which works only for string keywords, not for numerals or dates. The issues documentation adds that boolean and nested queries are available on a repository’s issues page, so one search box is read by two grammars according to where it was typed. ↩︎ Back to text