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 None

When 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 body

The 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 None

Each 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:

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 body

Net 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

OperatorSQLNotes
exact=Equality
iexactLOWER(x) = LOWER(?)Case-insensitive equality
contains / icontainsLIKE %?%Substring (case-insensitive on i variant)
startswith / istartswithLIKE ?%Prefix
endswith / iendswithLIKE %?Suffix
inIN (...)Empty list = rule skipped (no filter applied)
gt / gte / lt / ltecomparisonNumbers and dates
rangeBETWEENValue is [from, to]
isnullIS NULLFor text fields, also matches field = ''
isnotnullIS NOT NULLInverse of above
not_<op>wraps in ~QNegation prefix โ€” works on every operator above

Special values

Two sentinels make filtering on empty/null cleaner. They override the regular value:

ValueBecomes
"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:

OperatorEffect
todayBirthday today
this_monthBirthday this month
<op>_ageRange/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