OpenPredicate MCP server
Commit Sep 20, 2026Provides a JSON-encoded, SQL-flavored predicate language for standardized API search filtering and MCP tool input schemas.
Also known as OpenPredicate/open-predicate
- Stars
- 5
- Tools
- —
- Downloads, 30 days
- —
- Latest version
- —
- Last commit
- Sep 20, 2026
- License
- MIT
What is OpenPredicate MCP?
OpenPredicate provides a JSON-encoded, SQL-flavored predicate language for standardized API search filtering and MCP tool input schemas.
Summary generated from the project's README
About OpenPredicate MCP server
Standardizing API search filtering is what OpenPredicate enables for developers building structured query interfaces. By providing a JSON-encoded, SQL-flavored predicate language, it allows you to replace ad-hoc, opaque string parameters with a schema-validated format. This language is described by a single JSON Schema, making it easy to integrate into OpenAPI 3.1 POST search endpoints or OpenAPI 3.2 QUERY operations.
For those building MCP servers, you can use this schema as your tool inputSchema to provide a clear contract for AI agents. The language supports various profiles including core logical operators, comparison, ranges, strings, regular expressions, and collection quantifiers like $some or $every. Because it is built on JSON Schema, it works natively with existing API description layers and client generators. This approach ensures that both human developers and AI models learn one consistent language for filtering data across all your endpoints.
What you can do with it
- 1Define MCP tool inputsProvide an AI agent with a formal contract for writing complex filters using a single input schema.
- 2Implement complex collection queriesFilter records based on whether specific elements within an array satisfy certain conditions.
- 3Integrate with OpenAPI documentationUse a single schema reference to enable automated validation and type generation in API documentation.
Written from the project's README. Every use case is checked against a quote from it.
README
View on GitHubOpenPredicate
A JSON-encoded, SQL-flavoured predicate language, described by a single JSON Schema — $ref it from an OpenAPI document, or inline it into an MCP tool's inputSchema.
Write the filter grammar once. Use it for every POST /…/search and QUERY /… operation in your API, and for every search tool you expose to an agent. Clients learn one language instead of one ad-hoc query syntax per endpoint — and so do models.
{
"$and": [
{ "status": "available" },
{ "$or": [
{ "species": { "$in": ["cat", "dog"] } },
{ "tags": { "$some": { "$in": ["rescue", "senior"] } } }
]},
{ "born": { "$gte": "2020-01-01" } }
]
}
One schema, two integration points, because JSON Schema is what both already speak: it is the interchange format of OpenAPI 3.1, and it is what an MCP inputSchema is.
- Install —
npm i @open-predicate/open-predicate(Node >= 20.10) (npmjs.com). The package is the schema:require()it, orimport schema from '@open-predicate/open-predicate' with { type: 'json' }— ESM needs that import attribute, and a bareimportthrows without it. - Schema —
open-predicate-schema.json(JSON Schema draft 2020-12) - Semantics —
SPEC.md— nulls, paths, coercion, errors, limits - MCP server —
examples/mcp-server/— a search tool with the language as itsinputSchema, runnable - OpenAPI documents —
examples/— working 3.1 (POST /…/search) and 3.2 (QUERY) integrations - Generator —
tools/generate-filter-schema.mjs— turns a resource's JSON Schema, plus the slice of the language you can serve, into a per-field filter schema - Compared with GraphQL —
COMPARISON.md— what this overlaps with, what it does not, and what a JSON-Schema-native alternative would still need - Stewardship — the OpenPredicate organisation — see About OpenPredicate
- Version —
0.6.2. The schema's$idstill namesv0.4.0: the$idtracks the grammar, and neither 0.5.0 nor 0.6.0 touched it. SeeCHANGELOG.mdfor this release, anddecisions/0001for the v0.4.0 migration.
Pre-1.0, and installable. The name is settled — OpenPredicate, stewarded by the OpenPredicate organisation, with the repository, both package names, the schema
$idand the problem-type URIs all derived from it — the package is on npm as@open-predicate/open-predicate, and the schema is served at its$id— so$refit by URL, or from a packaged copy, whichever suits. One thing is still provisional: the grammar may still break before 1.0, with each break recorded inCHANGELOG.md. See Status for what is reachable today.
Why
Search endpoints attract bespoke query syntaxes. Each one arrives as an opaque string parameter (?q=status:open AND born>2020) that no schema can validate, no generator can type, and no client can build safely. Structuring the query as JSON changes that: it can be described by a JSON Schema, and a JSON Schema is the interchange format both of today's API description layers already speak. From OpenAPI it validates in CI, appears in generated docs, and produces real types in generated clients. As an MCP tool's inputSchema it becomes the contract an agent writes filters against, with the operator descriptions carried along as the instructions.
Confining the schema to the predicate — no projection, ordering or pagination — is what makes it reusable. Those parts differ per API; the filter does not.
The two rules worth learning first
Sibling members AND together. At every level.
{ "department": "sales", "age": { "$gte": 18 } }
is department = 'sales' AND age >= 18. That holds for operators on one field too — {"age": {"$gt": 18, "$ne": 30}} is a single valid constraint.
A bare scalar means equality. {"status": "open"} is shorthand for {"status": {"$eq": "open"}}. The shorthand covers strings, numbers, booleans and null only; arrays and objects must use an explicit operator, so {"tags": ["a"]} can never be misread as either $eq or $in.
Operator reference
Every operator below has a matching fixture in tests/fixtures/valid/ — the table and the test suite are the same list. Full semantics in SPEC.md §5.
Logical — profile core
| Operator | Example | Meaning |
|---|---|---|
$and |
{"$and": [{"a": 1}, {"b": 2}]} |
All must be TRUE |
$or |
{"$or": [{"a": 1}, {"b": 2}]} |
At least one TRUE |
$nor |
{"$nor": [{"a": 1}]} |
None TRUE |
$not |
{"$not": {"a": 1}} |
Negation |
Comparison — profile core
| Operator | Example | Meaning |
|---|---|---|
$eq |
{"name": {"$eq": "Alice"}} |
Equal. Accepts any JSON value, including null, arrays and objects |
$ne |
{"name": {"$ne": "Alice"}} |
Not equal |
$gt $gte |
{"price": {"$gt": 50}} |
Greater than / or equal |
$lt $lte |
{"born": {"$lte": "2023-12-31"}} |
Less than / or equal |
$in |
{"color": {"$in": ["red", "green"]}} |
Value is one of |
$nin |
{"color": {"$nin": ["red"]}} |
Value is none of |
$exists |
{"archivedAt": {"$exists": false}} |
Key present on the record |
$isNull |
{"middleName": {"$isNull": true}} |
Value is null |
$unknownAs |
{"s": {"$ne": "x", "$unknownAs": true}} |
Resolve this constraint's UNKNOWN to true/false |
Ranges — profile ranges
| Operator | Example | Meaning |
|---|---|---|
$between |
{"score": {"$between": [10, 20]}} |
Within [lo, hi], inclusive |
$nbetween |
{"score": {"$nbetween": [10, 20]}} |
Outside [lo, hi] |
Strings — profile strings
| Operator | Example | Meaning |
|---|---|---|
$like |
{"description": {"$like": "%urgent%"}} |
SQL pattern: % any run, _ one char, \ escapes |
$nlike |
{"code": {"$nlike": "TMP-%"}} |
Negated $like |
$ilike $nilike |
{"title": {"$ilike": "%k8s%"}} |
Case-insensitive $like |
$startsWith |
{"sku": {"$startsWith": "INV-"}} |
Literal prefix — wildcards not interpreted |
$endsWith |
{"file": {"$endsWith": ".pdf"}} |
Literal suffix |
$contains |
{"body": {"$contains": "100%"}} |
Literal substring. String-only — for arrays, quantify with $some |
Regular expressions — profile regex
| Operator | Example | Meaning |
|---|---|---|
$regex |
{"ref": {"$regex": "^inv-[0-9]{4}$"}} |
ECMA-262, matched unanchored |
$flags |
{"ref": {"$regex": "^inv-", "$flags": "i"}} |
i, m, s. Only valid alongside $regex |
Types — profile types
| Operator | Example | Meaning |
|---|---|---|
$type |
{"quantity": {"$type": "integer"}} |
One of string number integer boolean object array null |
Collections — profile collections
| Operator | Example | Meaning |
|---|---|---|
$some |
{"items": {"$some": {"qty": {"$gt": 2}}}} |
At least one element satisfies the condition |
$every |
{"tags": {"$every": {"$startsWith": "a-"}}} |
Every element satisfies it. TRUE for [] |
$hasAll |
{"tags": {"$hasAll": ["a", "b"]}} |
Array contains every member of the list |
$size |
{"tags": {"$size": {"$gte": 1}}} |
Array length — exact, or a comparison |
Field references — profile refs
| Operator | Example | Meaning |
|---|---|---|
$field |
{"price": {"$gt": {"$field": "cost"}}} |
Compare two fields — SQL's WHERE price > cost |
$literal |
{"p": {"$eq": {"$literal": {"$field": "x"}}}} |
Force an object operand to be read as data |
Free text — profile text
| Operator | Example | Meaning |
|---|---|---|
$search |
{"title": {"$search": "kubernetes ingress"}} |
Server-defined text match |
Three things that will bite you
$not does not include nulls. Evaluation is three-valued, like SQL. {"$not": {"status": {"$eq": "archived"}}} excludes records whose status is null, because NOT UNKNOWN is UNKNOWN and only TRUE matches. Say which you meant:
{ "status": { "$ne": "archived", "$unknownAs": true } }
$unknownAs resolves that constraint's UNKNOWN, applied after every sibling operator including a field-level $not. SPEC.md §4.6.
Missing is not null. {"a": null} and {} are different records. $exists tests the key, $isNull tests the value. SPEC.md §4.2 has the full table.
$in does not search inside arrays. It compares the value as a whole, so {"tags": {"$in": ["a"]}} asks whether tags equals "a". Element membership names its quantifier: {"tags": {"$some": {"$in": ["a"]}}}. This differs from MongoDB on purpose — overloading $in makes the meaning depend on data a validator cannot see.
Field paths
A member name is a path into the record:
| Path | Addresses |
|---|---|
name |
a top-level field |
address.city |
a nested field |
items[0].sku |
an array element by index |
a\.b |
a single key whose literal name contains a dot |
$$price |
a single key whose literal name is $price |
$ is reserved for operators, which is why a real $price field is escaped by doubling. A name starting with a single $ that is not a known operator is rejected — that is what turns $eqq into an error rather than a filter that matches everything.
A path addresses one position. To say something about an array's elements, quantify with $some or $every — and note that the nesting carries the scope: one $some with two conditions needs a single element to satisfy both, while two $some clauses may be satisfied by different elements. SPEC.md §5.9.
Using it from OpenAPI
One of the two integration paths this repo exists for; Exposing search to an agent covers the other. Complete, CI-linted documents live in examples/.
OpenAPI 3.1 — POST /…/search
The 3.1 Path Item Object has a fixed set of method fields (get, put, post, delete, options, head, patch, trace). QUERY is not among them, so on 3.1 a search with a body is a POST to a sub-resource:
paths:
/pets/search:
post:
operationId: searchPets
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PetSearchRequest'
responses:
'200': { $ref: '#/components/responses/PetPage' }
'400': { $ref: '#/components/responses/InvalidQuery' }
components:
schemas:
Filter:
$ref: 'https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json'
PetSearchRequest:
type: object
required: [filter]
properties:
filter: { $ref: '#/components/schemas/Filter' }
→ examples/openapi-3.1-post-search.yaml
OpenAPI 3.2 — the QUERY method
3.2 added additionalOperations, which is how methods outside the fixed set are described:
paths:
/pets:
additionalOperations:
QUERY:
operationId: queryPets
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PetSearchRequest' }
responses:
'200':
description: Matching pets
headers:
Content-Location:
schema: { type: string, format: uri-reference }
→ examples/openapi-3.2-query-method.yaml
QUERY (RFC 10008) is safe and idempotent and carries a request body — it says "this is a read" in a way POST cannot, so intermediaries may cache it and clients may retry it. Return Content-Location when the same representation is also reachable by GET.
It became a standards-track RFC in June 2026, so the method itself is settled — but deployed support in intermediaries, client libraries and gateways trails a fresh RFC by some margin. Ship POST /search alongside it and let clients pick.
Referencing by URL or by copy
Both work, and they trade off differently:
Absolute $id URL |
Bundled copy | |
|---|---|---|
$ref |
https://…/v0.4.0/open-predicate-schema.json |
./schemas/open-predicate-schema.json |
| Where the copy comes from | resolved at use | npm i @open-predicate/open-predicate, or curl from raw GitHub |
| Upgrades | change one URL | re-vendor the file |
| Tooling | needs a resolver that fetches remote refs | works everywhere |
MCP inputSchema |
no — nothing on that path resolves remote refs | yes, and it is the only option |
| Field restriction | not possible | see below |
If you bundle, keep the $id intact. Consumers can then tell which version they are looking at — and if you nest the schema inside a larger one, as an MCP inputSchema does, its self-references have a base to resolve against. See Exposing search to an agent, step 1.
The MCP row is not a preference. A server ships inputSchema inline in its tools/list response, so an absolute-URL $ref reaches the model as an opaque string and no grammar — see Exposing search to an agent.
Restricting the queryable field set
The default grammar accepts any field name, because the set of queryable paths belongs to your resource, not to the language. Servers MUST reject unknown fields at runtime (SPEC.md §3.5) — but you can also have it enforced by schema validation.
Bundle the schema and replace one definition, $defs/FieldPath:
{
"$id": "https://api.example.com/schemas/pet-filter.json",
"$ref": "#/$defs/Filter",
"$defs": {
"FieldPath": { "type": "string", "enum": ["id", "name", "status", "born"] },
"…": "everything else copied verbatim from open-predicate-schema.json"
}
}
The narrowing applies at every nesting level — inside $and, inside $not, inside $some and $every, and to $field references — because Filter reaches field names through propertyNames → $ref '#/$defs/FieldPath'. There is one override point, and this is it. (tests/validate.test.mjs exercises exactly this.)
Narrowing FieldPath restricts which fields may be named. It cannot restrict what may be said about them — every path still shares one Constraint. To get per-field operators and operand domains as well, generate the schema instead; see Generating a per-resource filter schema.
An earlier design used draft 2020-12
$dynamicRef/$dynamicAnchorso the override could be applied without copying the file. It was dropped: ajv 8.20 does not resolve it correctly even for the canonical recursive case, and OpenAPI tooling support is worse. A plain$refworks in every validator.
Conformance profiles
Not every backend can implement every operator, and silently ignoring a clause you cannot execute widens the result set — the worst possible failure for a filter. So operators are grouped into profiles, published in the schema under x-profiles:
core · strings · regex · ranges · types · collections · refs · text
Implement core in full; take the rest whole or not at all. Reject unsupported operators with an unsupported-operator error, and publish what you accept through a capability document. SPEC.md §2.
Errors
A rejected filter is a 400 that says which of five things went wrong — malformed-query · unknown-field · unsupported-operator · invalid-operand · query-too-complex — and, ideally, where. The envelope is your API's business; if you have no error format already, RFC 9457 Problem Details with a pointer at the offending clause is the recommended default:
{
"type": "https://openpredicate.tech/problems/unsupported-operator",
"title": "Unsupported operator",
"status": 400,
"detail": "$regex is not in this endpoint's advertised profiles (core, strings).",
"pointer": "/filter/$and/1/name/$regex"
}
What matters is that the condition is distinguishable and the client can recover from it — an unknown-field error carrying the endpoint's queryable paths saves a round trip of guessing. SPEC.md §8.
Generating a per-resource filter schema
The published grammar shares one Constraint definition across every field, which is what makes it reusable — and what stops it carrying per-field domains. It can tell you {"status": "Available"} is well-formed. It cannot tell you "Available" is not one of the three values status takes, so the filter is accepted and matches nothing.
If you already have a JSON Schema for the resource, that information is sitting right there. tools/generate-filter-schema.mjs reads it and emits a filter schema in which every queryable path has its own constraint subschema, carrying only the operators that apply to it and only the operands it can take.
node tools/generate-filter-schema.mjs examples/pet.schema.json \
--id https://api.example.com/schemas/pet.filter.json \
--profiles core,strings,ranges,collections \
--capabilities pet.capabilities.json \
--out pet.filter.json
Given examples/pet.schema.json, the generated pet.filter.json turns each of these from an empty result set into a 400:
| Filter | Rejected because |
|---|---|
{"status": "Available"} |
status is a closed domain of available, pending, sold |
{"tags": {"$in": ["urgent"]}} |
tags is an array; its element operators are $some, $every and $hasAll |
{"born": {"$gte": 2020}} |
born is a date-formatted string |
{"name": {"$gt": "M"}} |
ordering is offered on numbers and on date/time formats, not on free text |
{"species": {"$like": "ca%"}} |
pattern matching is not offered on an enumerated domain |
{"birthDate": "2020-01-01"} |
not a property of the resource |
It also writes the SPEC.md §2.2 capability document from the same source, so the schema and the published domains cannot drift apart.
That file is also what examples/mcp-server hands a model: a resource schema in, a tool definition out, with nothing written by hand in between.
What it decides, and why:
- Operators follow the type. Ordering and ranges go to numbers and to
date/date-time/timestrings; pattern matching goes to free text but not to enums or opaque formats likeuuid;$some/$everygo to arrays and recurse into arrays of objects, with$hasAlladded for arrays of scalars.$existsis omitted where the property is required all the way up,$isNullwhere the type does not admit null, and$unknownAswhere the field can be neither absent nor null — all three would be constants. - Operands follow the value domain.
$eq,$inand friends carry the field'senum,patternand bounds. The ordering operators deliberately do not:{"$gt": 0}against a field whoseminimumis 1 is a sensible predicate. - Prose comes from the grammar, not from the generator, so operator descriptions stay in one place.
--descriptions brief(the default) keeps them for the operators people get wrong and drops them for$eqand$gt, which matters when the output goes into an MCP tool definition. - Narrowing only. Every filter the generated schema accepts is also valid against the published grammar, so a server implementing the published semantics evaluates it unchanged. The rule that makes this hold: the constraint object's own keywords are copied from the grammar, and only its operator set and operand schemas are narrowed.
tests/generator.test.mjsasserts the property by sampling filters out of each generated schema's vocabulary — a list of examples can only re-check the leaks someone already thought of.
OpenPredicate MCP questions
How do I install OpenPredicate?
You can install it via npm using the command npm i @open-predicate/open-predicate (requires Node >= 20.10).
What is the difference between $exists and $isNull?
$exists tests if a key is present on the record, whereas $isNull tests if the value of a key is null.
Does $in search inside arrays?
No, $in compares the value as a whole. To check for element membership in an array, you must use a quantifier like $some.
How are sibling members in a JSON object treated?
Sibling members are treated as being joined by an AND operator at every level.