Filters
Three layers, from "URL params" to "JSON-encoded boolean trees stored as segments".
Layer 1 โ URL params
Any field listed in filter_fields is callable from both surfaces โ same whitelist, same lookup operators, same coercion rules.
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_space",
"arguments": {
"filter": {"active": true, "name__icontains": "demo"}
}
}
}๐ REST โ Querystring
GET /spaces?active=true&name__icontains=demo
Lookup operators (__icontains, __gte, __inโฆ) are accepted as long as the root field is in filter_fields. Strings "true" / "false" are converted to booleans.
Layer 2 โ JSON filter expression
Pass a JSON-encoded boolean tree. Same shape on both surfaces โ filter property in MCP, ?filter= querystring in REST.
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_space",
"arguments": {
"filter": {
"logical_operator": "AND",
"rules": [
{"field": "active", "operator": "exact", "value": true},
{
"logical_operator": "OR",
"rules": [
{"field": "name", "operator": "icontains", "value": "demo"},
{"field": "name", "operator": "icontains", "value": "test"}
]
}
]
}
}
}
}๐ REST โ ?filter=<urlencoded JSON>
{
"logical_operator": "AND",
"rules": [
{"field": "active", "operator": "exact", "value": true},
{
"logical_operator": "OR",
"rules": [
{"field": "name", "operator": "icontains", "value": "demo"},
{"field": "name", "operator": "icontains", "value": "test"}
]
}
]
}Every field is validated against filter_fields before being applied. Anything not on the whitelist throws 403.
Layer 3 โ Saved filter expressions
Layer 2 expressions can be persisted server-side and reapplied by id โ saved views, marketing audiences, dashboard presets, anything where you want a stable handle for a complex filter. The framework owns the URL plumbing; the project owns the storage, lookup and policy.
Two pieces on the resource:
class ClientResource(BaseResource):
model = Client
filter_fields = ['active', 'name']
stored_filter_param = 'segment_id' # any name โ view_id, audience_id, โฆ
async def resolve_stored_filter(self, value):
seg = await Segment.objects.filter(
id=value, account=self.account_id # tenant scoping etc.
).afirst()
return seg.conditions if seg else NoneWhen the request carries ?segment_id=42, 0-mcp calls the hook, trusts whatever it returns, and applies it through the same Layer-2 pipeline. Returning None raises 404 Stored filter not found. The hook can also raise HTTPException(403, ...) itself if the segment exists but the caller can't access it.
Combining with ?filter=:
GET /clients?segment_id=42&filter={"field":"city","operator":"exact","value":"Berlin"}Both layers compose with AND โ saved view plus an ad-hoc narrowing. The URL JSON still validates against filter_fields (caller-controlled input). The stored conditions skip that check because the hook is responsible for them.
For projects that let end users author stored expressions (a Segment-create endpoint), validate at write time using the public helper:
from zeromcp import validate_conditions
class SegmentResource(BaseResource):
model = Segment
create_fields = ['name', 'conditions', 'context_id']
update_fields = ['name', 'conditions']
async def hydrate(self, body):
conditions = body.get('conditions')
if conditions:
allowed = ALLOWED_FIELDS_BY_CONTEXT[body['context_id']]
validate_conditions(conditions, allowed)
return bodyThe whitelist is whatever your project decides โ typically a per-target-resource list of safe-to-filter columns. Server-side admin-only segments don't need this; user-authored segments do.
A minimum storage model is just a JSON column:
class Segment(models.Model):
name = models.CharField(max_length=120)
conditions = models.JSONField() # the Layer-2 boolean tree
created_at = models.DateTimeField(auto_now_add=True)End-to-end example with a mixin
Real projects usually have one Segment row type that targets several different list resources, each with its own per-target whitelist. A single mixin keeps the wiring DRY:
# modules/segment/mixin.py
from django.db.models import Q
from .constants import INCLUDE_FIELDS # project registry
from .models import Segment, CONTEXT
class SegmentMixin:
stored_filter_param = 'segment_id'
segment_context_id = None # subclass sets this
def __init__(self):
super().__init__()
if self.segment_context_id is None:
return
label = CONTEXT.LABEL[self.segment_context_id]
bag = INCLUDE_FIELDS.get(label, {})
extra = list(bag.get('segment_fields') or [])
extra += [m.split('__')[0] for m in bag.get('related_models') or []]
self.filter_fields = list(
dict.fromkeys((self.filter_fields or []) + extra)
)
async def resolve_stored_filter(self, value, **kwargs):
is_master = bool(self.user and (
self.user.get('is_admin') or self.user.get('is_owner')
))
qs = Segment.objects.filter(
id=value, context_id=self.segment_context_id,
)
if not is_master:
qs = qs.filter(
Q(public=True) | Q(created_by_id=self.user['id'])
)
seg = await qs.afirst()
return seg.conditions if seg else NoneEach list resource then opts in with two attributes:
class AgentResource(SegmentMixin, BaseResource):
segment_context_id = CONTEXT.AGENT
model = Agent
filter_fields = ['agency_id', 'last_seen'] # explicit URL shorthands
search_fields = ['name', 'email']Three things happen automatically because of the mixin:
?segment_id=Nresolves through the hook, scoped toCONTEXT.AGENT. Agency segments cannot leak into the agent endpoint โ the lookup filter itself rejects mismatched contexts.- Non-admins only resolve segments that are public or that they created themselves; admins resolve any. Tenant scoping is automatic via 0-mcp's per-request DB router.
filter_fieldsis unioned withINCLUDE_FIELDS['agent_Agent']['segment_fields']plus the related-model prefixes (agency,agency__country, โฆ), so?filter=<json>and segment authoring share a single source of truth instead of drifting into two parallel whitelists.
And the segment-create endpoint validates at write time:
class SegmentResource(BaseResource):
model = Segment
create_fields = ['name', 'conditions', 'context_id']
update_fields = ['name', 'conditions']
async def hydrate(self, body):
conditions = body.get('conditions')
if not conditions:
return body
context_id = body.get('context_id') or (self.obj and self.obj.context_id)
label = CONTEXT.LABEL.get(context_id)
if not label:
raise HTTPException(400, 'Invalid context_id')
allowed = INCLUDE_FIELDS[label]['segment_fields']
validate_conditions(conditions, allowed)
return bodyNet result: one INCLUDE_FIELDS registry drives the filter-builder UI, write-time segment validation, and read-time ?filter= validation. The framework knows nothing about Segment as a model โ every project shape (one segment per resource, one segment table for many resources, segments behind RBAC, segments shared across tenants) is reachable from the same hook.
Operator reference
| Operator | SQL | Notes |
|---|---|---|
exact | = | Equality |
iexact | LOWER(x) = LOWER(?) | Case-insensitive equality |
contains / icontains | LIKE %?% | Substring (case-insensitive on i variant) |
startswith / istartswith | LIKE ?% | Prefix |
endswith / iendswith | LIKE %? | Suffix |
in | IN (...) | Empty list = rule skipped (no filter applied) |
gt / gte / lt / lte | comparison | Numbers and dates |
range | BETWEEN | Value is [from, to] |
isnull | IS NULL | For text fields, also matches field = '' |
isnotnull | IS NOT NULL | Inverse of above |
not_<op> | wraps in ~Q | Negation prefix โ works on every operator above |
Special values
Two sentinels make filtering on empty/null cleaner. They override the regular value:
| Value | Becomes |
|---|---|
"Null" | field__isnull=True (or field__isnull=False with not_exact) |
"Blank" | field__exact='' โ empty string match |
Special fields
Some fields have built-in semantics in the Filter engine:
birthdate
Operators specific to age/dates:
| Operator | Effect |
|---|---|
today | Birthday today |
this_month | Birthday this month |
<op>_age | Range/exact/gte/lte by age in years (value is {type, value} or {min_value, max_value}) |
age
Same as birthdate but the value is the age itself. Translates internally to a date range.
custom_attributes__<name>
Filtering on user-defined custom fields. Uses an annotated subquery against the custom-attribute through-table. Checkbox-typed customs are detected and treated as booleans.
generated_creation_date
Annotates the queryset with a period key (year/quarter/month/day/hour) and filters on it. Powered by the Year/Quarter/Month/Day/Hour SQL Funcs in zeromcp/calc.py.
contacts__*
Reverse traversal into a contacts relation. The filter automatically excludes ContactStatus.DELETED (status_id=3) โ you only see active or pending contacts.
Search vs filter
?search=foo runs ICONTAINS (configurable via search_operator) across search_fields + ['id'], joined with OR. Search composes with filters โ the queryset is (filter_clauses) AND (search_clauses).
Field selection
Clients can ask for a subset of list_fields with ?fields=:
GET /users?fields=id,email
Fields not in list_fields are silently dropped โ no leaks.
0-mcp by Stamatios Stamou Jr โ github.com/ssjunior/0-mcp