# How to design filter and sort arguments for a list tool

> Named properties, a query mini-syntax, or a filter object with sort enums: three argument designs for one list tool, scored on ten prompts, then made to fail loudly.

Source: https://voxgig.com/howto/design-filter-and-sort-tool-arguments

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://modelcontextprotocol.io/specification/2025-11-25/server/tools), whose
`inputSchema` is a JSON Schema, and [Ajv](https://ajv.js.org/api.html) 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](https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-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](https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.2) | 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](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | 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.

```js title="designs.mjs"
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](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools)
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.

```js title="designs.mjs"
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](https://lucene.apache.org/core/10_2_0/queryparser/org/apache/lucene/queryparser/classic/package-summary.html)
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.

```js title="measure.mjs"
/** 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'
}
```

```bash
node measure.mjs
```

```text output
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](https://www.anthropic.com/research/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`](https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.3),
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.

```js title="tools.mjs"
/** 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](https://developers.openai.com/api/docs/guides/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.

```js title="query-syntax.mjs"
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](https://docs.stripe.com/search) 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.

```js title="tools.test.mjs"
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'))
})
```

```bash
node --test tools.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

- [Map one tool definition to MCP, OpenAI, Claude, and Gemini](/howto/convert-tool-definitions-between-mcp-openai-claude-gemini)

- [Design error responses an autonomous agent can act on](/howto/error-responses-an-agent-can-act-on)

- [Shape a tool result so an agent can reason about it](/howto/shape-tool-results-for-agent-reasoning)

- [Design one filter and sort vocabulary for all resources](/howto/consistent-filtering-and-sorting-parameters)

## 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.

[^1]: The [query parser syntax page](https://lucene.apache.org/core/10_2_0/queryparser/org/apache/lucene/queryparser/classic/package-summary.html)
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 `or` on this page broke. Agents were not the program the authors had in mind. They
are the program the advice fits.

[^2]: The [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
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.

[^3]: [Stripe's search query language](https://docs.stripe.com/search) allows up to ten clauses,
joined with `AND` or with `OR` but 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.

[^4]: GitHub's [search syntax](https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-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](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests)
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.