Cache
Opt-in Redis cache per resource, with namespace-based invalidation that does not blow away unrelated rows.
Enable on a resource
class SpaceResource(BaseResource):
model = Space
cache = True
cache_ttl = 600 # optional; framework default is 120sNow GET /spaces and GET /spaces/{id} are cached. POST/PATCH/DELETE invalidate automatically.
Project-level TTL controls
Two keys in the project's MCP bag override the framework default without touching resource code:
MCP = {
'CACHE_TTL': 300, # default 120s; framework default for resources
'CACHE_TTL_ENABLE': True, # default True; flip to False to kill cache globally
}CACHE_TTLonly changes the default โ resources that declarecache_ttl = Nkeep their explicit value.CACHE_TTL_ENABLE = Falseis a master kill switch: everycache = Trueis treated ascache = Falseat runtime, no Redis read, no Redis write. Useful for incident response without redeploy.
Cache key
Built from the request path and a hash of the querystring:
cache:/spaces:<md5(querystring)> cache:/spaces/42
When session_cache = True, the session id is folded into the key โ useful for endpoints whose response depends on the user.
Namespaces and invalidation
Every cached entry is also added to a Redis set keyed by namespace:
list:myapp.spaceโ all list responses for the modeldetail:myapp.space:42โ the detail page for row 42
Writes invalidate by namespace:
POST /spacesโ invalidateslist:myapp.spacePATCH /spaces/42โ invalidateslist:myapp.space+detail:myapp.space:42DELETE /spaces/42โ same as PATCH
Editing row 42 does not drop the cache for row 7. Lists are dropped because their contents may shift, but per-row pages stay warm.
Tenant isolation (automatic)
Multi-tenant deployments share Redis. _build_cache_key folds self.account_id into the key whenever it is set โ two tenants hitting the same path get different keys without any configuration. The fold uses account_id is not None, so an explicit account_id = 0 is treated as a real value (folded as a=0), not as absence.
# base prefix : [session_cache] : [a=<account_id>] : [scope=<hash>] : path : md5(qs) cache:a=42:/clients cache:sid-abc:a=42:/me
No need to declare cache_scope_fields = [('account', 'id')] โ the framework already isolates per tenant. cache_scope_fields is for dimensions inside the same tenant (role, space, plan).
Single-tenant deployments that want to keep the legacy key shape (no a= segment) can opt out via MCP = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False} in settings.py. Anonymous endpoints skip the fold automatically.
If your project overrides _build_cache_key, call self._account_cache_segment() and append the result to the key so the override inherits the tenant isolation. Otherwise the override silently re-introduces the cross-tenant cache leak.
Per-scope caching โ cache_scope_fields
When the response depends on a dimension carried by the session (user role, space, plan, country), declare it with cache_scope_fields. Users sharing the same scope share the cache; different scopes get isolated keys; the namespace-based invalidation is unchanged.
class TaskResource(BaseResource):
model = Task
cache = True
# Strings are shorthand for ('user', field). Tuples select the
# source explicitly: 'user' or 'account'.
cache_scope_fields = ['space_id', ('account', 'plan_id')]The framework folds the configured fields into a 16-char MD5 hash and prepends it to the request path inside the cache key. Anonymous requests (self.user is None and self.account is None) skip the fold silently.
Fail-safe. When auth context exists but a configured scope field is missing from the session payload, the framework logs a WARNING (logger zeromcp.base) and disables cache for that request โ the response is neither read from nor written to Redis. Sharing a key across users when the scope can't be resolved would silently leak data. Anonymous requests skip the fold cleanly. None, 0 and '' count as present; only true absence triggers the warning.
Coexists with session_cache = True. Both fold into the key without conflict โ session_cache gives full per-session isolation (lowest hit rate), cache_scope_fields keeps users on the same scope sharing cache (best hit rate when the dimension count is small).
before_cache hook
Escape hatch for the rare case that needs context outside self.user / self.account โ feature flags, request headers, time-of-day. Override before_cache(request):
async def before_cache(self, request):
flag = await get_feature_flag(self.user)
self.cache_key += f':flag={flag}'For routine user/account-derived dimensions, prefer cache_scope_fields โ it is declarative and centrally validated.
Custom routes
Custom routes opt in via the cache flag in their route definition:
routes = [
{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get'], 'cache': True},
]Hit ratio
Every request increments hit/miss counters in Redis:
cache_stats:hitsandcache_stats:missesโ global counters (INCR)cache_stats:by_modelโ Redis hash with fieldshits:<label>andmisses:<label>per resource (HINCRBY)
Use zeromcp.redis_config.get_cache_stats() to read them, or build an admin endpoint.
0-mcp by Stamatios Stamou Jr โ github.com/ssjunior/0-mcp