AsyncLocalStorage tenant + actor context propagation for MCP servers. Zero runtime dependencies, ESM-only, TypeScript.
Part of the StudioMeyer MCP Stack — Built in Mallorca 🌴 · ⭐ if you use it
mcp-tenant-context
Propagate a per-request tenant + actor identity through your handler stack
via AsyncLocalStorage — so tenantSlug and the request actor stay out of
every function signature. Zero runtime dependencies, just the Node stdlib.
Node: 22+ · License: MIT · Type: library (no server, no CLI)
A note from us
We have been building tools and systems for ourselves for the past two years. This repo is small and has few stars not because it is new, but because we only just decided to share what we had already built. The code is real, it runs in production, and issues get answered.
We love building things and sharing them. We do not love growth hacks or chasing stars. So this repo is small. Judge it by the code. If it helps you, a star, a test, or an issue helps us. If you build something with it, tell us at hello@studiomeyer.io — that genuinely makes our day.
From a small studio in Palma de Mallorca.
Why this exists
Every multi-tenant server has to thread two pieces of per-request identity through its handler stack:
tenantSlug— which tenant the request belongs to. It drives every scoped DB query (WHERE tenant_slug = $1) and is the core of cross-tenant isolation.actor— the human (OAuth user) or service principal (API key) that triggered the request. It drives audit-log entries and write attribution.
Passing these through every handler signature is boilerplate, and a forgotten
parameter is a silent bug. AsyncLocalStorage keeps the surface clean: set the
context once at the entry point, read it anywhere in the call stack. This library
is that pattern, packaged — about 115 lines, no dependencies, fully typed.
It is written with MCP servers in mind (the TenantContext carries an optional
sessionId matching the mcp-session-id header), but nothing here is
MCP-specific — it works for any request-scoped Node service.
Install
npm install mcp-tenant-context
Requires Node 22+.
Quickstart
import {
runWithTenantContext,
getTenantContext,
oauthActor,
} from "mcp-tenant-context";
// At the transport boundary — wrap every dispatch in a tenant context.
app.post("/mcp", async (req, res) => {
const slug = await authenticateBearer(req);
const email = await verifyOAuth(req);
await runWithTenantContext(
{ tenantSlug: slug, actor: oauthActor(email) },
async () => {
const result = await dispatchTool(req.body);
res.json(result);
},
);
});
// Anywhere inside a tool handler — no ctx threading needed.
async function listPages(args: { limit: number }) {
const ctx = getTenantContext(); // throws if no ctx is active
return db.query(
"SELECT * FROM pages WHERE tenant_slug = $1 LIMIT $2",
[ctx.tenantSlug, args.limit],
);
}
Public API
| Export | Purpose |
|--------|---------|
| runWithTenantContext<T>(ctx, fn) | Run fn with ctx installed. Preserves sync/async return type. |
| getTenantContext() | Current ctx, or throws NoTenantContextError. |
| getTenantContextOrUndefined() | Current ctx, or undefined. |
| withTenantContextHandler<TArgs, TResult>(handler) | Middleware that extracts ctx and passes it as the first arg. |
| oauthActor(email) | OAuth-backed actor. Display name derived from the email local part. |
| apiKeyActor(tenantSlug) | Synthetic service-account@<slug>.invalid actor (RFC 2606 reserved TLD). |
| anonymousActor() | Empty-email actor for unauthenticated paths. |
| TenantContext (type) | { tenantSlug, actor, traceId?, sessionId? }. |
| ActorIdentity (type) | { email, name, source }. |
| ActorSource (type) | "oauth" \| "api_key" \| "anonymous". |
| NoTenantContextError (class) | Specific, catchable error subclass. |
Middleware
withTenantContextHandler factors out the getTenantContext() boilerplate:
import { withTenantContextHandler } from "mcp-tenant-context";
export const listPages = withTenantContextHandler(
async (ctx, args: { limit: number }) => {
return db.query(
"SELECT * FROM pages WHERE tenant_slug = $1 LIMIT $2",
[ctx.tenantSlug, args.limit],
);
},
);
// Dispatch:
await runWithTenantContext(ctx, () => listPages({ limit: 10 }));
A handler invoked outside runWithTenantContext rejects with
NoTenantContextError (as a rejected promise, so every caller can .catch()
it uniformly).
Consumer responsibilities
This library does not validate or sanitise its inputs — by design, so it stays generic. The caller owns the security-relevant parts:
- Validate
tenantSlugbefore building a context (non-empty, expected charset, e.g.^[a-z0-9][a-z0-9-]{0,62}$). An empty or attacker-controlled slug handed toWHERE tenant_slug = $1is a cross-tenant risk. - Parameterise SQL —
tenantSlugis a plain string, never interpolate it. - Verify
emailin your identity layer and lowercase it beforeoauthActor(email). - Reject anonymous actors at the handler level where a tenant is required.
The library guarantees one thing: contexts do not bleed between concurrent scopes. Everything above the context is yours.
Context loss across callbacks and event emitters
AsyncLocalStorage follows async/await, promises, setTimeout and
queueMicrotask automatically (all covered by the test suite). It does not
follow a callback registered in a different async context — the classic case
is an EventEmitter listener (stream.on("data", ...), req.on("close", ...))
attached outside the tenant scope. Inside such a callback getTenantContext()
throws.
If you need the context inside a detached callback, capture a snapshot at registration time with the Node stdlib:
import { AsyncResource } from "node:async_hooks";
req.on("close", AsyncResource.bind(() => {
const ctx = getTenantContext(); // resolves to the captured scope
}));
A first-class bindTenantContext() snapshot helper is on the v0.2 roadmap.
The context also does not propagate into worker_threads — a spawned worker
starts with an empty store by design. Pass tenantSlug explicitly across the
worker boundary.
Performance
AsyncLocalStorage adds a small, constant per-await bookkeeping cost. For a
handler that does any real work (a DB round-trip, an LLM call) it is negligible
— the latency is dominated by the I/O, not the context lookup. Node documents
the implementation as "performant and memory safe". Only measure it if you have
an extremely hot, allocation-sensitive path.
Module instance
The store is a module-level singleton: every import of mcp-tenant-context in
the same process shares one store (intended — the context is process-wide). If
your dependency tree ends up with two different installed copies of this
package they will not share a store, so keep a single version (npm dedupe)
when several of your dependencies use it.
Edge cases covered by the test suite
- Nested
runWithTenantContext— inner overrides, outer is restored on resume. - Sibling concurrent runs — no bleed between parallel contexts.
- Async-iterator passthrough — ctx survives
for awaitboundaries. - Detached
setTimeoutcallbacks — ctx propagates through the timer. - Synchronous-vs-async
fnreturn type — preserved. - Optional
traceId+sessionIdfields. oauthActorwith pathological input: empty local part (@host), no@at all, separator-only local part, and an over-long local part (the derived name is capped, the email is preserved verbatim) — each returns a safe, non-throwing value.
27 tests across 3 files.
Versioning
Strict SemVer. No breaking change without a major bump
and a migration note in CHANGELOG.md. Pin with a caret
("mcp-tenant-context": "^0.1.0").
Related
Part of the StudioMeyer MCP stack — natural co-installs when building a multi-tenant MCP server with defense-in-depth:
mcp-tenant-pair— multi-user tenancy (couples, families, small groups) for consumer MCP servers.mcp-stdio-shellguard— default-deny guard forexec/spawnin MCP servers.mcp-rce-guard— process-isolation + CVE-replay defense for MCP subprocesses.
Contributing
Issues and PRs welcome — see CONTRIBUTING.md. Security reports go through the process in SECURITY.md.
License
MIT © Matthias Meyer (StudioMeyer)