Multi-tenant
One database per tenant, switched per request based on the active session. Your model code does not change. Queries cannot accidentally cross tenants.
How it works
When a session has an account.id, dispatch (or AuthMiddleware) calls aset_tenant(account_id) before running the handler:
- The function looks up the tenant DB connection details (cached in Redis, built from your
Account/Dbmodels on miss). - Builds and registers a Django connection for that tenant if not already present.
- Stores the active connection name (
<TENANT_DB_PREFIX>_<id>) in aContextVar(db_state).
DBRouter reads that ContextVar on every ORM query and routes to the matching connection. Models declared as "always master" stay on the default DB.
Setup
Add the router and tenant settings:
DATABASE_ROUTERS = ['zeromcp.DBRouter'] DEFAULT_DATABASE = DATABASES['default'] TENANT_ACCOUNT_MODEL = 'core.Account' TENANT_USER_MODEL = 'core.User' TENANT_USER_API_MODEL = 'core.UserApi' TENANT_DB_PREFIX = 'tenant' HASH_LENGTH = 32
Provide a model that holds tenant connection details. Minimum shape:
class Account(models.Model):
name = models.CharField(max_length=100)
status_id = models.IntegerField(default=1)
class Db(models.Model):
host = models.CharField(max_length=200)
user = models.CharField(max_length=100)
password = models.CharField(max_length=200)
account = models.OneToOneField(Account, on_delete=models.CASCADE,
related_name='db', null=True)API
All exported from the top-level zeromcp package — from zeromcp import set_tenant, aset_tenant, ....
| Function | Call from | What it does |
|---|---|---|
await aset_tenant(account_id) | async code | Switch the active DB connection to the given tenant. Builds the connection on first use, caches it in Redis, sets db_state. Raises HTTPException(400, 'Missing account') if the account does not exist. |
set_tenant(account_id) | sync code | Sync wrapper around aset_tenant — use from Celery tasks, management commands, signals, scripts. Calls async_to_sync internally. |
get_tenant() | anywhere | Returns the current tenant id from db_state, or None when no tenant is active. |
await set_default(account_id) | scripts only | Replaces the global connections.databases['default'] with the tenant's connection. Mutates process-wide state — not safe inside ASGI/request handling, where it would affect every concurrent request. Use only in one-off scripts/management commands. Import from zeromcp.tenant.tenant (not re-exported from 0-mcp). |
await unset_default(account_id) | scripts only | Restores connections.databases['default'] to the original DEFAULT_DATABASE. Same caveats as set_default. |
await get_account(domain) | async code | Resolves an account by its domain — useful for domain-routed APIs. |
Usage
In a resource
Nothing to do. Dispatch handles it automatically when the session has an account.
In a Django management command
from zeromcp import set_tenant
class Command(BaseCommand):
def handle(self, *args, account_id, **opts):
set_tenant(account_id)
# any ORM query from here on routes to the tenant DB
User.objects.update(...)In a Celery task
Celery tasks are typically sync — use set_tenant:
from zeromcp import set_tenant
@shared_task
def export_report(account_id):
set_tenant(account_id)
# any ORM query from here on routes to the tenant DB
rows = list(Report.objects.all())
...For async tasks (@shared_task with async def), use await aset_tenant(account_id).
Long-running scripts iterating over tenants
# script-only — mutates the global default connection.
# Do not call from inside an ASGI request.
from zeromcp.tenant.tenant import set_default, unset_default
for account_id in tenant_ids:
await set_default(account_id)
try:
# everything inside treats this tenant as the default DB
await run_migration_step()
finally:
await unset_default(account_id)Master DB
Models that should always live on the master DB (users, accounts, billing) are configured in the router. The router uses an allow-list pattern for "always master" — typically your auth and billing apps.
Connection caching
Tenant connection dictionaries are cached in Redis under <TENANT_DB_PREFIX>:connections:<id>. The first request after a deploy hits the master DB to build it; subsequent requests read from Redis. Updating the Db row for a tenant requires invalidating that cache key.
Multi-tenant routing is invisible to your model code. You write User.objects.all() — the router decides which DB. Your business logic does not know there are multiple databases. Your queries cannot cross tenants because the connection is gone.
0-mcp by Stamatios Stamou Jr — github.com/ssjunior/0-mcp